mirror of
https://github.com/openai/codex.git
synced 2026-02-05 16:33:42 +00:00
Compare commits
58 Commits
dev/cc/rel
...
network-sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb1767588 | ||
|
|
a575da87c4 | ||
|
|
e47d02ab27 | ||
|
|
73430c462f | ||
|
|
f7648cf2b5 | ||
|
|
78a5014430 | ||
|
|
4672cddaf2 | ||
|
|
9942765058 | ||
|
|
25ecd0c2e4 | ||
|
|
927a6acbea | ||
|
|
a9a7cf3488 | ||
|
|
df35189366 | ||
|
|
1e9babe178 | ||
|
|
3d92b443b0 | ||
|
|
167553f00d | ||
|
|
9f28c6251d | ||
|
|
3702793882 | ||
|
|
a2cc0032e0 | ||
|
|
f74e0cda92 | ||
|
|
ac6ba286aa | ||
|
|
9352c6b235 | ||
|
|
de3fa03e1c | ||
|
|
45c164a982 | ||
|
|
2e7e4f6ea6 | ||
|
|
0abaf1b57c | ||
|
|
2bf57674d6 | ||
|
|
813bdb9010 | ||
|
|
4897efcced | ||
|
|
2041b72da7 | ||
|
|
ebd1099b39 | ||
|
|
ae3793eb5d | ||
|
|
235c76e972 | ||
|
|
70913effc3 | ||
|
|
42b8f28ee8 | ||
|
|
14d80c35a9 | ||
|
|
3a0d9bca64 | ||
|
|
cafcd60ef0 | ||
|
|
600d01b33a | ||
|
|
3fbf379e02 | ||
|
|
a3b137d093 | ||
|
|
bbc5675974 | ||
|
|
51865695e4 | ||
|
|
3a32716e1c | ||
|
|
5ceeaa96b8 | ||
|
|
b27c702e83 | ||
|
|
e290d48264 | ||
|
|
3d14da9728 | ||
|
|
b53889aed5 | ||
|
|
d7482510b1 | ||
|
|
021c9a60e5 | ||
|
|
c9f5b9a6df | ||
|
|
ae57e18947 | ||
|
|
cf44511e77 | ||
|
|
05e6729875 | ||
|
|
bef36f4ae7 | ||
|
|
f074e5706b | ||
|
|
b9d1a087ee | ||
|
|
e2b5c918ad |
212
.github/actions/macos-code-sign/action.yml
vendored
Normal file
212
.github/actions/macos-code-sign/action.yml
vendored
Normal file
@@ -0,0 +1,212 @@
|
||||
name: macos-code-sign
|
||||
description: Configure, sign, notarize, and clean up macOS code signing artifacts.
|
||||
inputs:
|
||||
target:
|
||||
description: Rust compilation target triple (e.g. aarch64-apple-darwin).
|
||||
required: true
|
||||
apple-certificate:
|
||||
description: Base64-encoded Apple signing certificate (P12).
|
||||
required: true
|
||||
apple-certificate-password:
|
||||
description: Password for the signing certificate.
|
||||
required: true
|
||||
apple-notarization-key-p8:
|
||||
description: Base64-encoded Apple notarization key (P8).
|
||||
required: true
|
||||
apple-notarization-key-id:
|
||||
description: Apple notarization key ID.
|
||||
required: true
|
||||
apple-notarization-issuer-id:
|
||||
description: Apple notarization issuer ID.
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure Apple code signing
|
||||
shell: bash
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: actions
|
||||
APPLE_CERTIFICATE: ${{ inputs.apple-certificate }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.apple-certificate-password }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
|
||||
echo "APPLE_CERTIFICATE is required for macOS signing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then
|
||||
echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12"
|
||||
echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path"
|
||||
|
||||
keychain_path="${RUNNER_TEMP}/codex-signing.keychain-db"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
security set-keychain-settings -lut 21600 "$keychain_path"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
|
||||
keychain_args=()
|
||||
cleanup_keychain() {
|
||||
if ((${#keychain_args[@]} > 0)); then
|
||||
security list-keychains -s "${keychain_args[@]}" || true
|
||||
security default-keychain -s "${keychain_args[0]}" || true
|
||||
else
|
||||
security list-keychains -s || true
|
||||
fi
|
||||
if [[ -f "$keychain_path" ]]; then
|
||||
security delete-keychain "$keychain_path" || true
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r keychain; do
|
||||
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
|
||||
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
|
||||
|
||||
if ((${#keychain_args[@]} > 0)); then
|
||||
security list-keychains -s "$keychain_path" "${keychain_args[@]}"
|
||||
else
|
||||
security list-keychains -s "$keychain_path"
|
||||
fi
|
||||
|
||||
security default-keychain -s "$keychain_path"
|
||||
security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null
|
||||
|
||||
codesign_hashes=()
|
||||
while IFS= read -r hash; do
|
||||
[[ -n "$hash" ]] && codesign_hashes+=("$hash")
|
||||
done < <(security find-identity -v -p codesigning "$keychain_path" \
|
||||
| sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \
|
||||
| sort -u)
|
||||
|
||||
if ((${#codesign_hashes[@]} == 0)); then
|
||||
echo "No signing identities found in $keychain_path"
|
||||
cleanup_keychain
|
||||
rm -f "$cert_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ((${#codesign_hashes[@]} > 1)); then
|
||||
echo "Multiple signing identities found in $keychain_path:"
|
||||
printf ' %s\n' "${codesign_hashes[@]}"
|
||||
cleanup_keychain
|
||||
rm -f "$cert_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}"
|
||||
|
||||
rm -f "$cert_path"
|
||||
|
||||
echo "APPLE_CODESIGN_IDENTITY=$APPLE_CODESIGN_IDENTITY" >> "$GITHUB_ENV"
|
||||
echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
|
||||
echo "::add-mask::$APPLE_CODESIGN_IDENTITY"
|
||||
|
||||
- name: Sign macOS binaries
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then
|
||||
echo "APPLE_CODESIGN_IDENTITY is required for macOS signing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
keychain_args=()
|
||||
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then
|
||||
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
|
||||
fi
|
||||
|
||||
for binary in codex codex-responses-api-proxy; do
|
||||
path="codex-rs/target/${{ inputs.target }}/release/${binary}"
|
||||
codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
|
||||
done
|
||||
|
||||
- name: Notarize macOS binaries
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }}
|
||||
APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }}
|
||||
APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
echo "$var is required for notarization"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
notary_key_path="${RUNNER_TEMP}/notarytool.key.p8"
|
||||
echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path"
|
||||
cleanup_notary() {
|
||||
rm -f "$notary_key_path"
|
||||
}
|
||||
trap cleanup_notary EXIT
|
||||
|
||||
notarize_binary() {
|
||||
local binary="$1"
|
||||
local source_path="codex-rs/target/${{ inputs.target }}/release/${binary}"
|
||||
local archive_path="${RUNNER_TEMP}/${binary}.zip"
|
||||
|
||||
if [[ ! -f "$source_path" ]]; then
|
||||
echo "Binary $source_path not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$archive_path"
|
||||
ditto -c -k --keepParent "$source_path" "$archive_path"
|
||||
|
||||
submission_json=$(xcrun notarytool submit "$archive_path" \
|
||||
--key "$notary_key_path" \
|
||||
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
|
||||
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
|
||||
--output-format json \
|
||||
--wait)
|
||||
|
||||
status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"')
|
||||
submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""')
|
||||
|
||||
if [[ -z "$submission_id" ]]; then
|
||||
echo "Failed to retrieve submission ID for $binary"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}"
|
||||
|
||||
if [[ "$status" != "Accepted" ]]; then
|
||||
echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
notarize_binary "codex"
|
||||
notarize_binary "codex-responses-api-proxy"
|
||||
|
||||
- name: Remove signing keychain
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" ]]; then
|
||||
keychain_args=()
|
||||
while IFS= read -r keychain; do
|
||||
[[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue
|
||||
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
|
||||
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
|
||||
if ((${#keychain_args[@]} > 0)); then
|
||||
security list-keychains -s "${keychain_args[@]}"
|
||||
security default-keychain -s "${keychain_args[0]}"
|
||||
fi
|
||||
|
||||
if [[ -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then
|
||||
security delete-keychain "$APPLE_CODESIGN_KEYCHAIN"
|
||||
fi
|
||||
fi
|
||||
24
.github/dotslash-config.json
vendored
24
.github/dotslash-config.json
vendored
@@ -55,6 +55,30 @@
|
||||
"path": "codex-responses-api-proxy.exe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex-command-runner": {
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-command-runner.exe"
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-command-runner.exe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex-windows-sandbox-setup": {
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-windows-sandbox-setup.exe"
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-windows-sandbox-setup.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -36,7 +36,8 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CODEX_VERSION=0.40.0
|
||||
# Use a rust-release version that includes all native binaries.
|
||||
CODEX_VERSION=0.74.0-alpha.3
|
||||
OUTPUT_DIR="${RUNNER_TEMP}"
|
||||
python3 ./scripts/stage_npm_packages.py \
|
||||
--release-version "$CODEX_VERSION" \
|
||||
|
||||
6
.github/workflows/rust-ci.yml
vendored
6
.github/workflows/rust-ci.yml
vendored
@@ -28,9 +28,11 @@ jobs:
|
||||
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
BASE_SHA='${{ github.event.pull_request.base.sha }}'
|
||||
HEAD_SHA='${{ github.event.pull_request.head.sha }}'
|
||||
echo "Base SHA: $BASE_SHA"
|
||||
# List files changed between base and current HEAD (merge-base aware)
|
||||
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA"...HEAD)
|
||||
echo "Head SHA: $HEAD_SHA"
|
||||
# List files changed between base and PR head
|
||||
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA" "$HEAD_SHA")
|
||||
else
|
||||
# On push / manual runs, default to running everything
|
||||
files=("codex-rs/force" ".github/force")
|
||||
|
||||
51
.github/workflows/rust-release-prepare.yml
vendored
Normal file
51
.github/workflows/rust-release-prepare.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: rust-release-prepare
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */4 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update models.json
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.CODEX_OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
client_version="99.99.99"
|
||||
terminal_info="github-actions"
|
||||
user_agent="codex_cli_rs/99.99.99 (Linux $(uname -r); $(uname -m)) ${terminal_info}"
|
||||
base_url="${OPENAI_BASE_URL:-https://chatgpt.com/backend-api/codex}"
|
||||
|
||||
headers=(
|
||||
-H "Authorization: Bearer ${OPENAI_API_KEY}"
|
||||
-H "User-Agent: ${user_agent}"
|
||||
)
|
||||
|
||||
url="${base_url%/}/models?client_version=${client_version}"
|
||||
curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json
|
||||
|
||||
- name: Open pull request (if changed)
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: "Update models.json"
|
||||
title: "Update models.json"
|
||||
body: "Automated update of models.json."
|
||||
branch: "bot/update-models-json"
|
||||
reviewers: "pakrym-oai,aibrahim-oai"
|
||||
delete-branch: true
|
||||
203
.github/workflows/rust-release.yml
vendored
203
.github/workflows/rust-release.yml
vendored
@@ -129,173 +129,15 @@ jobs:
|
||||
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
|
||||
|
||||
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
|
||||
name: Configure Apple code signing
|
||||
shell: bash
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: actions
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
|
||||
echo "APPLE_CERTIFICATE is required for macOS signing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then
|
||||
echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12"
|
||||
echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path"
|
||||
|
||||
keychain_path="${RUNNER_TEMP}/codex-signing.keychain-db"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
security set-keychain-settings -lut 21600 "$keychain_path"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
|
||||
keychain_args=()
|
||||
cleanup_keychain() {
|
||||
if ((${#keychain_args[@]} > 0)); then
|
||||
security list-keychains -s "${keychain_args[@]}" || true
|
||||
security default-keychain -s "${keychain_args[0]}" || true
|
||||
else
|
||||
security list-keychains -s || true
|
||||
fi
|
||||
if [[ -f "$keychain_path" ]]; then
|
||||
security delete-keychain "$keychain_path" || true
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r keychain; do
|
||||
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
|
||||
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
|
||||
|
||||
if ((${#keychain_args[@]} > 0)); then
|
||||
security list-keychains -s "$keychain_path" "${keychain_args[@]}"
|
||||
else
|
||||
security list-keychains -s "$keychain_path"
|
||||
fi
|
||||
|
||||
security default-keychain -s "$keychain_path"
|
||||
security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null
|
||||
|
||||
codesign_hashes=()
|
||||
while IFS= read -r hash; do
|
||||
[[ -n "$hash" ]] && codesign_hashes+=("$hash")
|
||||
done < <(security find-identity -v -p codesigning "$keychain_path" \
|
||||
| sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \
|
||||
| sort -u)
|
||||
|
||||
if ((${#codesign_hashes[@]} == 0)); then
|
||||
echo "No signing identities found in $keychain_path"
|
||||
cleanup_keychain
|
||||
rm -f "$cert_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ((${#codesign_hashes[@]} > 1)); then
|
||||
echo "Multiple signing identities found in $keychain_path:"
|
||||
printf ' %s\n' "${codesign_hashes[@]}"
|
||||
cleanup_keychain
|
||||
rm -f "$cert_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}"
|
||||
|
||||
rm -f "$cert_path"
|
||||
|
||||
echo "APPLE_CODESIGN_IDENTITY=$APPLE_CODESIGN_IDENTITY" >> "$GITHUB_ENV"
|
||||
echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
|
||||
echo "::add-mask::$APPLE_CODESIGN_IDENTITY"
|
||||
|
||||
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
|
||||
name: Sign macOS binaries
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then
|
||||
echo "APPLE_CODESIGN_IDENTITY is required for macOS signing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
keychain_args=()
|
||||
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then
|
||||
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
|
||||
fi
|
||||
|
||||
for binary in codex codex-responses-api-proxy; do
|
||||
path="target/${{ matrix.target }}/release/${binary}"
|
||||
codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
|
||||
done
|
||||
|
||||
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
|
||||
name: Notarize macOS binaries
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
|
||||
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
|
||||
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
echo "$var is required for notarization"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
notary_key_path="${RUNNER_TEMP}/notarytool.key.p8"
|
||||
echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path"
|
||||
cleanup_notary() {
|
||||
rm -f "$notary_key_path"
|
||||
}
|
||||
trap cleanup_notary EXIT
|
||||
|
||||
notarize_binary() {
|
||||
local binary="$1"
|
||||
local source_path="target/${{ matrix.target }}/release/${binary}"
|
||||
local archive_path="${RUNNER_TEMP}/${binary}.zip"
|
||||
|
||||
if [[ ! -f "$source_path" ]]; then
|
||||
echo "Binary $source_path not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$archive_path"
|
||||
ditto -c -k --keepParent "$source_path" "$archive_path"
|
||||
|
||||
submission_json=$(xcrun notarytool submit "$archive_path" \
|
||||
--key "$notary_key_path" \
|
||||
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
|
||||
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
|
||||
--output-format json \
|
||||
--wait)
|
||||
|
||||
status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"')
|
||||
submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""')
|
||||
|
||||
if [[ -z "$submission_id" ]]; then
|
||||
echo "Failed to retrieve submission ID for $binary"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}"
|
||||
|
||||
if [[ "$status" != "Accepted" ]]; then
|
||||
echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
notarize_binary "codex"
|
||||
notarize_binary "codex-responses-api-proxy"
|
||||
name: MacOS code signing
|
||||
uses: ./.github/actions/macos-code-sign
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
|
||||
apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
|
||||
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
|
||||
|
||||
- name: Stage artifacts
|
||||
shell: bash
|
||||
@@ -380,29 +222,6 @@ jobs:
|
||||
zstd "${zstd_args[@]}" "$dest/$base"
|
||||
done
|
||||
|
||||
- name: Remove signing keychain
|
||||
if: ${{ always() && matrix.runner == 'macos-15-xlarge' }}
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" ]]; then
|
||||
keychain_args=()
|
||||
while IFS= read -r keychain; do
|
||||
[[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue
|
||||
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
|
||||
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
|
||||
if ((${#keychain_args[@]} > 0)); then
|
||||
security list-keychains -s "${keychain_args[@]}"
|
||||
security default-keychain -s "${keychain_args[0]}"
|
||||
fi
|
||||
|
||||
if [[ -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then
|
||||
security delete-keychain "$APPLE_CODESIGN_KEYCHAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
@@ -487,7 +306,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js for npm packaging
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -538,7 +357,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
2
.github/workflows/sdk.yml
vendored
2
.github/workflows/sdk.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
2
.github/workflows/shell-tool-mcp-ci.yml
vendored
2
.github/workflows/shell-tool-mcp-ci.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
|
||||
4
.github/workflows/shell-tool-mcp.yml
vendored
4
.github/workflows/shell-tool-mcp.yml
vendored
@@ -280,7 +280,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
@@ -376,7 +376,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
@@ -75,6 +75,7 @@ If you don’t have the tool:
|
||||
|
||||
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
|
||||
- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields.
|
||||
- Avoid mutating process environment in tests; prefer passing environment-derived flags or dependencies from above.
|
||||
|
||||
### Integration tests (core)
|
||||
|
||||
|
||||
@@ -20,9 +20,14 @@ PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
|
||||
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
|
||||
"codex-sdk": ["codex"],
|
||||
}
|
||||
WINDOWS_ONLY_COMPONENTS: dict[str, list[str]] = {
|
||||
"codex": ["codex-windows-sandbox-setup", "codex-command-runner"],
|
||||
}
|
||||
COMPONENT_DEST_DIR: dict[str, str] = {
|
||||
"codex": "codex",
|
||||
"codex-responses-api-proxy": "codex-responses-api-proxy",
|
||||
"codex-windows-sandbox-setup": "codex",
|
||||
"codex-command-runner": "codex",
|
||||
"rg": "path",
|
||||
}
|
||||
|
||||
@@ -103,7 +108,7 @@ def main() -> int:
|
||||
"pointing to a directory containing pre-installed binaries."
|
||||
)
|
||||
|
||||
copy_native_binaries(vendor_src, staging_dir, native_components)
|
||||
copy_native_binaries(vendor_src, staging_dir, package, native_components)
|
||||
|
||||
if release_version:
|
||||
staging_dir_str = str(staging_dir)
|
||||
@@ -232,7 +237,12 @@ def stage_codex_sdk_sources(staging_dir: Path) -> None:
|
||||
shutil.copy2(license_src, staging_dir / "LICENSE")
|
||||
|
||||
|
||||
def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[str]) -> None:
|
||||
def copy_native_binaries(
|
||||
vendor_src: Path,
|
||||
staging_dir: Path,
|
||||
package: str,
|
||||
components: list[str],
|
||||
) -> None:
|
||||
vendor_src = vendor_src.resolve()
|
||||
if not vendor_src.exists():
|
||||
raise RuntimeError(f"Vendor source directory not found: {vendor_src}")
|
||||
@@ -250,6 +260,9 @@ def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[s
|
||||
if not target_dir.is_dir():
|
||||
continue
|
||||
|
||||
if "windows" in target_dir.name:
|
||||
components_set.update(WINDOWS_ONLY_COMPONENTS.get(package, []))
|
||||
|
||||
dest_target_dir = vendor_dest / target_dir.name
|
||||
dest_target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@ class BinaryComponent:
|
||||
artifact_prefix: str # matches the artifact filename prefix (e.g. codex-<target>.zst)
|
||||
dest_dir: str # directory under vendor/<target>/ where the binary is installed
|
||||
binary_basename: str # executable name inside dest_dir (before optional .exe)
|
||||
targets: tuple[str, ...] | None = None # limit installation to specific targets
|
||||
|
||||
|
||||
WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target)
|
||||
|
||||
BINARY_COMPONENTS = {
|
||||
"codex": BinaryComponent(
|
||||
artifact_prefix="codex",
|
||||
@@ -49,6 +52,18 @@ BINARY_COMPONENTS = {
|
||||
dest_dir="codex-responses-api-proxy",
|
||||
binary_basename="codex-responses-api-proxy",
|
||||
),
|
||||
"codex-windows-sandbox-setup": BinaryComponent(
|
||||
artifact_prefix="codex-windows-sandbox-setup",
|
||||
dest_dir="codex",
|
||||
binary_basename="codex-windows-sandbox-setup",
|
||||
targets=WINDOWS_TARGETS,
|
||||
),
|
||||
"codex-command-runner": BinaryComponent(
|
||||
artifact_prefix="codex-command-runner",
|
||||
dest_dir="codex",
|
||||
binary_basename="codex-command-runner",
|
||||
targets=WINDOWS_TARGETS,
|
||||
),
|
||||
}
|
||||
|
||||
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
|
||||
@@ -79,7 +94,8 @@ def parse_args() -> argparse.Namespace:
|
||||
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
|
||||
help=(
|
||||
"Limit installation to the specified components."
|
||||
" May be repeated. Defaults to 'codex' and 'rg'."
|
||||
" May be repeated. Defaults to codex, codex-windows-sandbox-setup,"
|
||||
" codex-command-runner, and rg."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -101,7 +117,12 @@ def main() -> int:
|
||||
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
|
||||
vendor_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
components = args.components or ["codex", "rg"]
|
||||
components = args.components or [
|
||||
"codex",
|
||||
"codex-windows-sandbox-setup",
|
||||
"codex-command-runner",
|
||||
"rg",
|
||||
]
|
||||
|
||||
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
|
||||
if not workflow_url:
|
||||
@@ -116,8 +137,7 @@ def main() -> int:
|
||||
install_binary_components(
|
||||
artifacts_dir,
|
||||
vendor_dir,
|
||||
BINARY_TARGETS,
|
||||
[name for name in components if name in BINARY_COMPONENTS],
|
||||
[BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS],
|
||||
)
|
||||
|
||||
if "rg" in components:
|
||||
@@ -206,23 +226,19 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
|
||||
def install_binary_components(
|
||||
artifacts_dir: Path,
|
||||
vendor_dir: Path,
|
||||
targets: Iterable[str],
|
||||
component_names: Sequence[str],
|
||||
selected_components: Sequence[BinaryComponent],
|
||||
) -> None:
|
||||
selected_components = [BINARY_COMPONENTS[name] for name in component_names if name in BINARY_COMPONENTS]
|
||||
if not selected_components:
|
||||
return
|
||||
|
||||
targets = list(targets)
|
||||
if not targets:
|
||||
return
|
||||
|
||||
for component in selected_components:
|
||||
component_targets = list(component.targets or BINARY_TARGETS)
|
||||
|
||||
print(
|
||||
f"Installing {component.binary_basename} binaries for targets: "
|
||||
+ ", ".join(targets)
|
||||
+ ", ".join(component_targets)
|
||||
)
|
||||
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
|
||||
max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1)))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
@@ -232,7 +248,7 @@ def install_binary_components(
|
||||
target,
|
||||
component,
|
||||
): target
|
||||
for target in targets
|
||||
for target in component_targets
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
installed_path = future.result()
|
||||
|
||||
1834
codex-rs/Cargo.lock
generated
1834
codex-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ members = [
|
||||
"login",
|
||||
"mcp-server",
|
||||
"mcp-types",
|
||||
"network-proxy",
|
||||
"ollama",
|
||||
"process-hardening",
|
||||
"protocol",
|
||||
@@ -83,6 +84,7 @@ codex-lmstudio = { path = "lmstudio" }
|
||||
codex-login = { path = "login" }
|
||||
codex-mcp-server = { path = "mcp-server" }
|
||||
codex-ollama = { path = "ollama" }
|
||||
codex-network-proxy = { path = "network-proxy" }
|
||||
codex-otel = { path = "otel" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
@@ -135,6 +137,7 @@ env_logger = "0.11.5"
|
||||
escargot = "0.5"
|
||||
eventsource-stream = "0.2.3"
|
||||
futures = { version = "0.3", default-features = false }
|
||||
globset = "0.4"
|
||||
http = "1.3.1"
|
||||
icu_decimal = "2.1"
|
||||
icu_locale_core = "2.1"
|
||||
|
||||
@@ -144,9 +144,9 @@ client_request_definitions! {
|
||||
response: v2::McpServerOauthLoginResponse,
|
||||
},
|
||||
|
||||
McpServersList => "mcpServers/list" {
|
||||
params: v2::ListMcpServersParams,
|
||||
response: v2::ListMcpServersResponse,
|
||||
McpServerStatusList => "mcpServerStatus/list" {
|
||||
params: v2::ListMcpServerStatusParams,
|
||||
response: v2::ListMcpServerStatusResponse,
|
||||
},
|
||||
|
||||
LoginAccount => "account/login/start" {
|
||||
@@ -525,6 +525,8 @@ server_notification_definitions! {
|
||||
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
|
||||
ItemStarted => "item/started" (v2::ItemStartedNotification),
|
||||
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
|
||||
/// This event is internal-only. Used by Codex Cloud.
|
||||
RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification),
|
||||
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
|
||||
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
||||
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
||||
@@ -657,6 +659,7 @@ mod tests {
|
||||
parsed_cmd: vec![ParsedCommand::Unknown {
|
||||
cmd: "echo hello".to_string(),
|
||||
}],
|
||||
network_preflight_only: false,
|
||||
};
|
||||
let request = ServerRequest::ExecCommandApproval {
|
||||
request_id: RequestId::Integer(7),
|
||||
@@ -678,7 +681,8 @@ mod tests {
|
||||
"type": "unknown",
|
||||
"cmd": "echo hello"
|
||||
}
|
||||
]
|
||||
],
|
||||
"networkPreflightOnly": false
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
|
||||
@@ -227,6 +227,8 @@ pub struct ExecCommandApprovalParams {
|
||||
pub cwd: PathBuf,
|
||||
pub reason: Option<String>,
|
||||
pub parsed_cmd: Vec<ParsedCommand>,
|
||||
#[serde(default)]
|
||||
pub network_preflight_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
||||
@@ -209,13 +209,24 @@ v2_enum_from_core!(
|
||||
);
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ConfigLayerName {
|
||||
Mdm,
|
||||
System,
|
||||
pub enum ConfigLayerSource {
|
||||
/// Managed preferences layer delivered by MDM (macOS only).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Mdm { domain: String, key: String },
|
||||
/// Managed config layer from a file (usually `managed_config.toml`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
System { file: AbsolutePathBuf },
|
||||
/// Session-layer overrides supplied via `-c`/`--config`.
|
||||
SessionFlags,
|
||||
User,
|
||||
/// User config layer from a file (usually `config.toml`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
User { file: AbsolutePathBuf },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
||||
@@ -288,8 +299,7 @@ pub struct Config {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConfigLayerMetadata {
|
||||
pub name: ConfigLayerName,
|
||||
pub source: String,
|
||||
pub name: ConfigLayerSource,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
@@ -297,8 +307,7 @@ pub struct ConfigLayerMetadata {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConfigLayer {
|
||||
pub name: ConfigLayerName,
|
||||
pub source: String,
|
||||
pub name: ConfigLayerSource,
|
||||
pub version: String,
|
||||
pub config: JsonValue,
|
||||
}
|
||||
@@ -761,7 +770,7 @@ pub struct ModelListResponse {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ListMcpServersParams {
|
||||
pub struct ListMcpServerStatusParams {
|
||||
/// Opaque pagination cursor returned by a previous call.
|
||||
pub cursor: Option<String>,
|
||||
/// Optional page size; defaults to a server-defined value.
|
||||
@@ -771,7 +780,7 @@ pub struct ListMcpServersParams {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServer {
|
||||
pub struct McpServerStatus {
|
||||
pub name: String,
|
||||
pub tools: std::collections::HashMap<String, McpTool>,
|
||||
pub resources: Vec<McpResource>,
|
||||
@@ -782,8 +791,8 @@ pub struct McpServer {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ListMcpServersResponse {
|
||||
pub data: Vec<McpServer>,
|
||||
pub struct ListMcpServerStatusResponse {
|
||||
pub data: Vec<McpServerStatus>,
|
||||
/// Opaque cursor to pass to the next call to continue after the last item.
|
||||
/// If None, there are no more items to return.
|
||||
pub next_cursor: Option<String>,
|
||||
@@ -860,6 +869,12 @@ pub struct ThreadStartParams {
|
||||
pub config: Option<HashMap<String, JsonValue>>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
/// If true, opt into emitting raw response items on the event stream.
|
||||
///
|
||||
/// This is for internal use only (e.g. Codex Cloud).
|
||||
/// (TODO): Figure out a better way to categorize internal / experimental events & protocols.
|
||||
#[serde(default)]
|
||||
pub experimental_raw_events: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -981,6 +996,7 @@ pub struct SkillsListResponse {
|
||||
pub enum SkillScope {
|
||||
User,
|
||||
Repo,
|
||||
Public,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -1026,6 +1042,7 @@ impl From<CoreSkillScope> for SkillScope {
|
||||
match value {
|
||||
CoreSkillScope::User => Self::User,
|
||||
CoreSkillScope::Repo => Self::Repo,
|
||||
CoreSkillScope::Public => Self::Public,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1581,6 +1598,15 @@ pub struct ItemCompletedNotification {
|
||||
pub turn_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RawResponseItemCompletedNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item: ResponseItem,
|
||||
}
|
||||
|
||||
// Item-specific progress notifications
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -27,6 +27,7 @@ codex-protocol = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-feedback = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-json-to-toml = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Protocol](#protocol)
|
||||
- [Message Schema](#message-schema)
|
||||
- [Core Primitives](#core-primitives)
|
||||
@@ -28,6 +29,7 @@ codex app-server generate-json-schema --out DIR
|
||||
## Core Primitives
|
||||
|
||||
The API exposes three top level primitives representing an interaction between a user and Codex:
|
||||
|
||||
- **Thread**: A conversation between a user and the Codex agent. Each thread contains multiple turns.
|
||||
- **Turn**: One turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items.
|
||||
- **Item**: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. Example items include user message, agent reasoning, agent message, shell command, file edit, etc.
|
||||
@@ -49,13 +51,23 @@ Clients must send a single `initialize` request before invoking any other method
|
||||
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
|
||||
|
||||
Example (from OpenAI's official VSCode extension):
|
||||
|
||||
```json
|
||||
{ "method": "initialize", "id": 0, "params": {
|
||||
"clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" }
|
||||
} }
|
||||
{
|
||||
"method": "initialize",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"clientInfo": {
|
||||
"name": "codex-vscode",
|
||||
"title": "Codex VS Code Extension",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Overview
|
||||
|
||||
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
|
||||
@@ -67,7 +79,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `model/list` — list available models (with reasoning effort options).
|
||||
- `skills/list` — list skills for one or more `cwd` values.
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
|
||||
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
- `config/read` — fetch the effective config on disk after resolving config layering.
|
||||
@@ -108,6 +120,7 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev
|
||||
### Example: List threads (with pagination & filters)
|
||||
|
||||
`thread/list` lets you render a history UI. Pass any combination of:
|
||||
|
||||
- `cursor` — opaque string from a prior response; omit for the first page.
|
||||
- `limit` — server defaults to a reasonable page size if unset.
|
||||
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
|
||||
@@ -228,22 +241,32 @@ Codex streams the usual `turn/started` notification followed by an `item/started
|
||||
with an `enteredReviewMode` item so clients can show progress:
|
||||
|
||||
```json
|
||||
{ "method": "item/started", "params": { "item": {
|
||||
"type": "enteredReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "current changes"
|
||||
} } }
|
||||
{
|
||||
"method": "item/started",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "enteredReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "current changes"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When the reviewer finishes, the server emits `item/started` and `item/completed`
|
||||
containing an `exitedReviewMode` item with the final review text:
|
||||
|
||||
```json
|
||||
{ "method": "item/completed", "params": { "item": {
|
||||
"type": "exitedReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
|
||||
} } }
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "exitedReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client.
|
||||
@@ -263,6 +286,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Empty `command` arrays are rejected.
|
||||
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags).
|
||||
- When omitted, `timeoutMs` falls back to the server default.
|
||||
@@ -285,6 +309,7 @@ Today both notifications carry an empty `items` array even when item events were
|
||||
#### Items
|
||||
|
||||
`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items:
|
||||
|
||||
- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`).
|
||||
- `agentMessage` — `{id, text}` containing the accumulated agent reply.
|
||||
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
|
||||
@@ -298,37 +323,48 @@ Today both notifications carry an empty `items` array even when item events were
|
||||
- `compacted` - `{threadId, turnId}` when codex compacts the conversation history. This can happen automatically.
|
||||
|
||||
All items emit two shared lifecycle events:
|
||||
|
||||
- `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas.
|
||||
- `item/completed` — sends the final `item` once that work finishes (e.g., after a tool call or message completes); treat this as the authoritative state.
|
||||
|
||||
There are additional item-specific events:
|
||||
|
||||
#### agentMessage
|
||||
|
||||
- `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply.
|
||||
|
||||
#### reasoning
|
||||
|
||||
- `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens.
|
||||
- `item/reasoning/summaryPartAdded` — marks the boundary between reasoning summary sections for an `itemId`; subsequent `summaryTextDelta` entries share the same `summaryIndex`.
|
||||
- `item/reasoning/textDelta` — streams raw reasoning text (only applicable for e.g. open source models); use `contentIndex` to group deltas that belong together before showing them in the UI.
|
||||
|
||||
#### commandExecution
|
||||
|
||||
- `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item.
|
||||
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
|
||||
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
|
||||
|
||||
#### fileChange
|
||||
|
||||
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
|
||||
|
||||
### 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
|
||||
`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.
|
||||
|
||||
@@ -342,6 +378,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap
|
||||
### Command execution approvals
|
||||
|
||||
Order of messages:
|
||||
|
||||
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
|
||||
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `reason` or `risk`, plus `parsedCmd` for friendly display.
|
||||
3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`.
|
||||
@@ -350,6 +387,7 @@ Order of messages:
|
||||
### File change approvals
|
||||
|
||||
Order of messages:
|
||||
|
||||
1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user.
|
||||
2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, and an optional `reason`.
|
||||
3. Client response — `{ "decision": "accept" }` or `{ "decision": "decline" }`.
|
||||
@@ -362,6 +400,7 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives.
|
||||
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
|
||||
|
||||
### API Overview
|
||||
|
||||
- `account/read` — fetch current account info; optionally refresh tokens.
|
||||
- `account/login/start` — begin login (`apiKey` or `chatgpt`).
|
||||
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
|
||||
@@ -375,11 +414,13 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
|
||||
### 1) Check auth state
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "method": "account/read", "id": 1, "params": { "refreshToken": false } }
|
||||
```
|
||||
|
||||
Response examples:
|
||||
|
||||
```json
|
||||
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
|
||||
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
|
||||
@@ -388,6 +429,7 @@ Response examples:
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
- `refreshToken` (bool): set `true` to force a token refresh.
|
||||
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
|
||||
|
||||
@@ -395,7 +437,11 @@ Field notes:
|
||||
|
||||
1. Send:
|
||||
```json
|
||||
{ "method": "account/login/start", "id": 2, "params": { "type": "apiKey", "apiKey": "sk-…" } }
|
||||
{
|
||||
"method": "account/login/start",
|
||||
"id": 2,
|
||||
"params": { "type": "apiKey", "apiKey": "sk-…" }
|
||||
}
|
||||
```
|
||||
2. Expect:
|
||||
```json
|
||||
@@ -445,6 +491,7 @@ Field notes:
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
- `usedPercent` is current usage within the OpenAI quota window.
|
||||
- `windowDurationMins` is the quota window length.
|
||||
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
|
||||
|
||||
@@ -31,6 +31,7 @@ use codex_app_server_protocol::McpToolCallResult;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind;
|
||||
use codex_app_server_protocol::RawResponseItemCompletedNotification;
|
||||
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
|
||||
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
|
||||
use codex_app_server_protocol::ReasoningTextDeltaNotification;
|
||||
@@ -181,6 +182,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
reason,
|
||||
proposed_execpolicy_amendment,
|
||||
parsed_cmd,
|
||||
network_preflight_only,
|
||||
..
|
||||
}) => match api_version {
|
||||
ApiVersion::V1 => {
|
||||
let params = ExecCommandApprovalParams {
|
||||
@@ -190,6 +193,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
cwd,
|
||||
reason,
|
||||
parsed_cmd,
|
||||
network_preflight_only,
|
||||
};
|
||||
let rx = outgoing
|
||||
.send_request(ServerRequestPayload::ExecCommandApproval(params))
|
||||
@@ -451,6 +455,16 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.send_server_notification(ServerNotification::ItemCompleted(completed))
|
||||
.await;
|
||||
}
|
||||
EventMsg::RawResponseItem(raw_response_item_event) => {
|
||||
maybe_emit_raw_response_item_completed(
|
||||
api_version,
|
||||
conversation_id,
|
||||
&event_turn_id,
|
||||
raw_response_item_event.item,
|
||||
outgoing.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventMsg::PatchApplyBegin(patch_begin_event) => {
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
@@ -820,6 +834,27 @@ async fn complete_command_execution_item(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn maybe_emit_raw_response_item_completed(
|
||||
api_version: ApiVersion,
|
||||
conversation_id: ConversationId,
|
||||
turn_id: &str,
|
||||
item: codex_protocol::models::ResponseItem,
|
||||
outgoing: &OutgoingMessageSender,
|
||||
) {
|
||||
let ApiVersion::V2 = api_version else {
|
||||
return;
|
||||
};
|
||||
|
||||
let notification = RawResponseItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::RawResponseItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn find_and_remove_turn_summary(
|
||||
conversation_id: ConversationId,
|
||||
turn_summary_store: &TurnSummaryStore,
|
||||
|
||||
@@ -46,8 +46,8 @@ use codex_app_server_protocol::InterruptConversationParams;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ListConversationsParams;
|
||||
use codex_app_server_protocol::ListConversationsResponse;
|
||||
use codex_app_server_protocol::ListMcpServersParams;
|
||||
use codex_app_server_protocol::ListMcpServersResponse;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
use codex_app_server_protocol::LoginAccountParams;
|
||||
use codex_app_server_protocol::LoginApiKeyParams;
|
||||
use codex_app_server_protocol::LoginApiKeyResponse;
|
||||
@@ -55,10 +55,10 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_app_server_protocol::LoginChatGptResponse;
|
||||
use codex_app_server_protocol::LogoutAccountResponse;
|
||||
use codex_app_server_protocol::LogoutChatGptResponse;
|
||||
use codex_app_server_protocol::McpServer;
|
||||
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
|
||||
use codex_app_server_protocol::McpServerOauthLoginParams;
|
||||
use codex_app_server_protocol::McpServerOauthLoginResponse;
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::NewConversationParams;
|
||||
@@ -393,13 +393,20 @@ impl CodexMessageProcessor {
|
||||
self.handle_list_conversations(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ModelList { request_id, params } => {
|
||||
self.list_models(request_id, params).await;
|
||||
let outgoing = self.outgoing.clone();
|
||||
let conversation_manager = self.conversation_manager.clone();
|
||||
let config = self.config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::list_models(outgoing, conversation_manager, config, request_id, params)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
ClientRequest::McpServerOauthLogin { request_id, params } => {
|
||||
self.mcp_server_oauth_login(request_id, params).await;
|
||||
}
|
||||
ClientRequest::McpServersList { request_id, params } => {
|
||||
self.list_mcp_servers(request_id, params).await;
|
||||
ClientRequest::McpServerStatusList { request_id, params } => {
|
||||
self.list_mcp_server_status(request_id, params).await;
|
||||
}
|
||||
ClientRequest::LoginAccount { request_id, params } => {
|
||||
self.login_v2(request_id, params).await;
|
||||
@@ -1165,7 +1172,15 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
|
||||
let env = create_env(&self.config.shell_environment_policy);
|
||||
let effective_policy = params
|
||||
.sandbox_policy
|
||||
.map(|policy| policy.to_core())
|
||||
.unwrap_or_else(|| self.config.sandbox_policy.clone());
|
||||
let env = create_env(
|
||||
&self.config.shell_environment_policy,
|
||||
&effective_policy,
|
||||
&self.config.network_proxy,
|
||||
);
|
||||
let timeout_ms = params
|
||||
.timeout_ms
|
||||
.and_then(|timeout_ms| u64::try_from(timeout_ms).ok());
|
||||
@@ -1179,11 +1194,6 @@ impl CodexMessageProcessor {
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let effective_policy = params
|
||||
.sandbox_policy
|
||||
.map(|policy| policy.to_core())
|
||||
.unwrap_or_else(|| self.config.sandbox_policy.clone());
|
||||
|
||||
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
|
||||
let outgoing = self.outgoing.clone();
|
||||
let req_id = request_id;
|
||||
@@ -1373,9 +1383,13 @@ impl CodexMessageProcessor {
|
||||
};
|
||||
|
||||
// Auto-attach a conversation listener when starting a thread.
|
||||
// Use the same behavior as the v1 API with experimental_raw_events=false.
|
||||
// Use the same behavior as the v1 API, with opt-in support for raw item events.
|
||||
if let Err(err) = self
|
||||
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
|
||||
.attach_conversation_listener(
|
||||
conversation_id,
|
||||
params.experimental_raw_events,
|
||||
ApiVersion::V2,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
@@ -1892,9 +1906,17 @@ impl CodexMessageProcessor {
|
||||
Ok((items, next_cursor))
|
||||
}
|
||||
|
||||
async fn list_models(&self, request_id: RequestId, params: ModelListParams) {
|
||||
async fn list_models(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
config: Arc<Config>,
|
||||
request_id: RequestId,
|
||||
params: ModelListParams,
|
||||
) {
|
||||
let ModelListParams { limit, cursor } = params;
|
||||
let models = supported_models(self.conversation_manager.clone(), &self.config).await;
|
||||
let mut config = (*config).clone();
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
let models = supported_models(conversation_manager, &config).await;
|
||||
let total = models.len();
|
||||
|
||||
if total == 0 {
|
||||
@@ -1902,7 +1924,7 @@ impl CodexMessageProcessor {
|
||||
data: Vec::new(),
|
||||
next_cursor: None,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
outgoing.send_response(request_id, response).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1917,7 +1939,7 @@ impl CodexMessageProcessor {
|
||||
message: format!("invalid cursor: {cursor}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
@@ -1930,7 +1952,7 @@ impl CodexMessageProcessor {
|
||||
message: format!("cursor {start} exceeds total models {total}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1945,7 +1967,7 @@ impl CodexMessageProcessor {
|
||||
data: items,
|
||||
next_cursor,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn mcp_server_oauth_login(
|
||||
@@ -2052,7 +2074,12 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) {
|
||||
async fn list_mcp_server_status(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
params: ListMcpServerStatusParams,
|
||||
) {
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let config = match self.load_latest_config().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
@@ -2061,6 +2088,17 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::list_mcp_server_status_task(outgoing, request_id, params, config).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn list_mcp_server_status_task(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
request_id: RequestId,
|
||||
params: ListMcpServerStatusParams,
|
||||
config: Config,
|
||||
) {
|
||||
let snapshot = collect_mcp_snapshot(&config).await;
|
||||
|
||||
let tools_by_server = group_tools_by_server(&snapshot.tools);
|
||||
@@ -2088,7 +2126,7 @@ impl CodexMessageProcessor {
|
||||
message: format!("invalid cursor: {cursor}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
@@ -2101,15 +2139,15 @@ impl CodexMessageProcessor {
|
||||
message: format!("cursor {start} exceeds total MCP servers {total}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let end = start.saturating_add(effective_limit).min(total);
|
||||
|
||||
let data: Vec<McpServer> = server_names[start..end]
|
||||
let data: Vec<McpServerStatus> = server_names[start..end]
|
||||
.iter()
|
||||
.map(|name| McpServer {
|
||||
.map(|name| McpServerStatus {
|
||||
name: name.clone(),
|
||||
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
|
||||
resources: snapshot.resources.get(name).cloned().unwrap_or_default(),
|
||||
@@ -2133,9 +2171,9 @@ impl CodexMessageProcessor {
|
||||
None
|
||||
};
|
||||
|
||||
let response = ListMcpServersResponse { data, next_cursor };
|
||||
let response = ListMcpServerStatusResponse { data, next_cursor };
|
||||
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn handle_resume_conversation(
|
||||
|
||||
@@ -274,6 +274,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
|
||||
parsed_cmd: vec![ParsedCommand::Unknown {
|
||||
cmd: "python3 -c 'print(42)'".to_string()
|
||||
}],
|
||||
network_preflight_only: false,
|
||||
},
|
||||
params
|
||||
);
|
||||
|
||||
@@ -25,12 +25,13 @@ async fn get_user_agent_returns_current_codex_user_agent() -> Result<()> {
|
||||
.await??;
|
||||
|
||||
let os_info = os_info::get();
|
||||
let originator = codex_core::default_client::originator().value.as_str();
|
||||
let os_type = os_info.os_type();
|
||||
let os_version = os_info.version();
|
||||
let architecture = os_info.architecture().unwrap_or("unknown");
|
||||
let terminal_ua = codex_core::terminal::user_agent();
|
||||
let user_agent = format!(
|
||||
"codex_cli_rs/0.0.0 ({} {}; {}) {} (codex-app-server-tests; 0.1.0)",
|
||||
os_info.os_type(),
|
||||
os_info.version(),
|
||||
os_info.architecture().unwrap_or("unknown"),
|
||||
codex_core::terminal::user_agent()
|
||||
"{originator}/0.0.0 ({os_type} {os_version}; {architecture}) {terminal_ua} (codex-app-server-tests; 0.1.0)"
|
||||
);
|
||||
|
||||
let received: GetUserAgentResponse = to_response(response)?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use app_test_support::to_response;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigEdit;
|
||||
use codex_app_server_protocol::ConfigLayerName;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigReadResponse;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
@@ -18,6 +18,7 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_app_server_protocol::ToolsV2;
|
||||
use codex_app_server_protocol::WriteStatus;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
@@ -42,6 +43,8 @@ model = "gpt-user"
|
||||
sandbox_mode = "workspace-write"
|
||||
"#,
|
||||
)?;
|
||||
let codex_home_path = codex_home.path().canonicalize()?;
|
||||
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
@@ -65,12 +68,14 @@ sandbox_mode = "workspace-write"
|
||||
assert_eq!(config.model.as_deref(), Some("gpt-user"));
|
||||
assert_eq!(
|
||||
origins.get("model").expect("origin").name,
|
||||
ConfigLayerName::User
|
||||
ConfigLayerSource::User {
|
||||
file: user_file.clone(),
|
||||
}
|
||||
);
|
||||
let layers = layers.expect("layers present");
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(layers[0].name, ConfigLayerName::SessionFlags);
|
||||
assert_eq!(layers[1].name, ConfigLayerName::User);
|
||||
assert_eq!(layers[0].name, ConfigLayerSource::SessionFlags);
|
||||
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -88,6 +93,8 @@ web_search = true
|
||||
view_image = false
|
||||
"#,
|
||||
)?;
|
||||
let codex_home_path = codex_home.path().canonicalize()?;
|
||||
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
@@ -118,17 +125,21 @@ view_image = false
|
||||
);
|
||||
assert_eq!(
|
||||
origins.get("tools.web_search").expect("origin").name,
|
||||
ConfigLayerName::User
|
||||
ConfigLayerSource::User {
|
||||
file: user_file.clone(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
origins.get("tools.view_image").expect("origin").name,
|
||||
ConfigLayerName::User
|
||||
ConfigLayerSource::User {
|
||||
file: user_file.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
let layers = layers.expect("layers present");
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(layers[0].name, ConfigLayerName::SessionFlags);
|
||||
assert_eq!(layers[1].name, ConfigLayerName::User);
|
||||
assert_eq!(layers[0].name, ConfigLayerSource::SessionFlags);
|
||||
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -153,8 +164,11 @@ network_access = true
|
||||
serde_json::json!(user_dir)
|
||||
),
|
||||
)?;
|
||||
let codex_home_path = codex_home.path().canonicalize()?;
|
||||
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
|
||||
|
||||
let managed_path = codex_home.path().join("managed_config.toml");
|
||||
let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?;
|
||||
std::fs::write(
|
||||
&managed_path,
|
||||
format!(
|
||||
@@ -197,19 +211,25 @@ writable_roots = [{}]
|
||||
assert_eq!(config.model.as_deref(), Some("gpt-system"));
|
||||
assert_eq!(
|
||||
origins.get("model").expect("origin").name,
|
||||
ConfigLayerName::System
|
||||
ConfigLayerSource::System {
|
||||
file: managed_file.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(config.approval_policy, Some(AskForApproval::Never));
|
||||
assert_eq!(
|
||||
origins.get("approval_policy").expect("origin").name,
|
||||
ConfigLayerName::System
|
||||
ConfigLayerSource::System {
|
||||
file: managed_file.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite));
|
||||
assert_eq!(
|
||||
origins.get("sandbox_mode").expect("origin").name,
|
||||
ConfigLayerName::User
|
||||
ConfigLayerSource::User {
|
||||
file: user_file.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
let sandbox = config
|
||||
@@ -222,7 +242,9 @@ writable_roots = [{}]
|
||||
.get("sandbox_workspace_write.writable_roots.0")
|
||||
.expect("origin")
|
||||
.name,
|
||||
ConfigLayerName::System
|
||||
ConfigLayerSource::System {
|
||||
file: managed_file.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
assert!(sandbox.network_access);
|
||||
@@ -231,14 +253,19 @@ writable_roots = [{}]
|
||||
.get("sandbox_workspace_write.network_access")
|
||||
.expect("origin")
|
||||
.name,
|
||||
ConfigLayerName::User
|
||||
ConfigLayerSource::User {
|
||||
file: user_file.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
let layers = layers.expect("layers present");
|
||||
assert_eq!(layers.len(), 3);
|
||||
assert_eq!(layers[0].name, ConfigLayerName::System);
|
||||
assert_eq!(layers[1].name, ConfigLayerName::SessionFlags);
|
||||
assert_eq!(layers[2].name, ConfigLayerName::User);
|
||||
assert_eq!(
|
||||
layers[0].name,
|
||||
ConfigLayerSource::System { file: managed_file }
|
||||
);
|
||||
assert_eq!(layers[1].name, ConfigLayerSource::SessionFlags);
|
||||
assert_eq!(layers[2].name, ConfigLayerSource::User { file: user_file });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -48,51 +48,61 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
|
||||
let expected_models = vec![
|
||||
Model {
|
||||
id: "gpt-5.1-codex-max".to_string(),
|
||||
model: "gpt-5.1-codex-max".to_string(),
|
||||
display_name: "gpt-5.1-codex-max".to_string(),
|
||||
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
|
||||
id: "gpt-5.1".to_string(),
|
||||
model: "gpt-5.1".to_string(),
|
||||
display_name: "gpt-5.1".to_string(),
|
||||
description: "Broad world knowledge with strong general reasoning.".to_string(),
|
||||
supported_reasoning_efforts: vec![
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Low,
|
||||
description: "Fast responses with lighter reasoning".to_string(),
|
||||
description: "Balances speed with some reasoning; useful for straightforward \
|
||||
queries and short explanations"
|
||||
.to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Medium,
|
||||
description: "Balances speed and reasoning depth for everyday tasks"
|
||||
description: "Provides a solid balance of reasoning depth and latency for \
|
||||
general-purpose tasks"
|
||||
.to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::High,
|
||||
description: "Greater reasoning depth for complex problems".to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::XHigh,
|
||||
description: "Extra high reasoning depth for complex problems".to_string(),
|
||||
description: "Maximizes reasoning depth for complex or ambiguous problems"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
default_reasoning_effort: ReasoningEffort::Medium,
|
||||
is_default: true,
|
||||
},
|
||||
Model {
|
||||
id: "gpt-5.1-codex".to_string(),
|
||||
model: "gpt-5.1-codex".to_string(),
|
||||
display_name: "gpt-5.1-codex".to_string(),
|
||||
description: "Optimized for codex.".to_string(),
|
||||
id: "gpt-5.2".to_string(),
|
||||
model: "gpt-5.2".to_string(),
|
||||
display_name: "gpt-5.2".to_string(),
|
||||
description:
|
||||
"Latest frontier model with improvements across knowledge, reasoning and coding"
|
||||
.to_string(),
|
||||
supported_reasoning_efforts: vec![
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Low,
|
||||
description: "Fastest responses with limited reasoning".to_string(),
|
||||
description: "Balances speed with some reasoning; useful for straightforward \
|
||||
queries and short explanations"
|
||||
.to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Medium,
|
||||
description: "Dynamically adjusts reasoning based on the task".to_string(),
|
||||
description: "Provides a solid balance of reasoning depth and latency for \
|
||||
general-purpose tasks"
|
||||
.to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::High,
|
||||
description: "Maximizes reasoning depth for complex or ambiguous problems"
|
||||
.to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::XHigh,
|
||||
description: "Extra high reasoning for complex problems".to_string(),
|
||||
},
|
||||
],
|
||||
default_reasoning_effort: ReasoningEffort::Medium,
|
||||
is_default: false,
|
||||
@@ -117,60 +127,50 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
is_default: false,
|
||||
},
|
||||
Model {
|
||||
id: "gpt-5.2".to_string(),
|
||||
model: "gpt-5.2".to_string(),
|
||||
display_name: "gpt-5.2".to_string(),
|
||||
description:
|
||||
"Latest frontier model with improvements across knowledge, reasoning and coding"
|
||||
.to_string(),
|
||||
id: "gpt-5.1-codex".to_string(),
|
||||
model: "gpt-5.1-codex".to_string(),
|
||||
display_name: "gpt-5.1-codex".to_string(),
|
||||
description: "Optimized for codex.".to_string(),
|
||||
supported_reasoning_efforts: vec![
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Low,
|
||||
description: "Balances speed with some reasoning; useful for straightforward \
|
||||
queries and short explanations"
|
||||
.to_string(),
|
||||
description: "Fastest responses with limited reasoning".to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Medium,
|
||||
description: "Provides a solid balance of reasoning depth and latency for \
|
||||
general-purpose tasks"
|
||||
.to_string(),
|
||||
description: "Dynamically adjusts reasoning based on the task".to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::High,
|
||||
description: "Greater reasoning depth for complex or ambiguous problems"
|
||||
description: "Maximizes reasoning depth for complex or ambiguous problems"
|
||||
.to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::XHigh,
|
||||
description: "Extra high reasoning for complex problems".to_string(),
|
||||
},
|
||||
],
|
||||
default_reasoning_effort: ReasoningEffort::Medium,
|
||||
is_default: false,
|
||||
},
|
||||
Model {
|
||||
id: "gpt-5.1".to_string(),
|
||||
model: "gpt-5.1".to_string(),
|
||||
display_name: "gpt-5.1".to_string(),
|
||||
description: "Broad world knowledge with strong general reasoning.".to_string(),
|
||||
id: "gpt-5.1-codex-max".to_string(),
|
||||
model: "gpt-5.1-codex-max".to_string(),
|
||||
display_name: "gpt-5.1-codex-max".to_string(),
|
||||
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
|
||||
supported_reasoning_efforts: vec![
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Low,
|
||||
description: "Balances speed with some reasoning; useful for straightforward \
|
||||
queries and short explanations"
|
||||
.to_string(),
|
||||
description: "Fast responses with lighter reasoning".to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::Medium,
|
||||
description: "Provides a solid balance of reasoning depth and latency for \
|
||||
general-purpose tasks"
|
||||
description: "Balances speed and reasoning depth for everyday tasks"
|
||||
.to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::High,
|
||||
description: "Maximizes reasoning depth for complex or ambiguous problems"
|
||||
.to_string(),
|
||||
description: "Greater reasoning depth for complex problems".to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: ReasoningEffort::XHigh,
|
||||
description: "Extra high reasoning depth for complex problems".to_string(),
|
||||
},
|
||||
],
|
||||
default_reasoning_effort: ReasoningEffort::Medium,
|
||||
@@ -210,7 +210,7 @@ async fn list_models_pagination_works() -> Result<()> {
|
||||
} = to_response::<ModelListResponse>(first_response)?;
|
||||
|
||||
assert_eq!(first_items.len(), 1);
|
||||
assert_eq!(first_items[0].id, "gpt-5.1-codex-max");
|
||||
assert_eq!(first_items[0].id, "gpt-5.1");
|
||||
let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?;
|
||||
|
||||
let second_request = mcp
|
||||
@@ -232,7 +232,7 @@ async fn list_models_pagination_works() -> Result<()> {
|
||||
} = to_response::<ModelListResponse>(second_response)?;
|
||||
|
||||
assert_eq!(second_items.len(), 1);
|
||||
assert_eq!(second_items[0].id, "gpt-5.1-codex");
|
||||
assert_eq!(second_items[0].id, "gpt-5.2");
|
||||
let third_cursor = second_cursor.ok_or_else(|| anyhow!("cursor for third page"))?;
|
||||
|
||||
let third_request = mcp
|
||||
@@ -276,7 +276,7 @@ async fn list_models_pagination_works() -> Result<()> {
|
||||
} = to_response::<ModelListResponse>(fourth_response)?;
|
||||
|
||||
assert_eq!(fourth_items.len(), 1);
|
||||
assert_eq!(fourth_items[0].id, "gpt-5.2");
|
||||
assert_eq!(fourth_items[0].id, "gpt-5.1-codex");
|
||||
let fifth_cursor = fourth_cursor.ok_or_else(|| anyhow!("cursor for fifth page"))?;
|
||||
|
||||
let fifth_request = mcp
|
||||
@@ -298,7 +298,7 @@ async fn list_models_pagination_works() -> Result<()> {
|
||||
} = to_response::<ModelListResponse>(fifth_response)?;
|
||||
|
||||
assert_eq!(fifth_items.len(), 1);
|
||||
assert_eq!(fifth_items[0].id, "gpt-5.1");
|
||||
assert_eq!(fifth_items[0].id, "gpt-5.1-codex-max");
|
||||
assert!(fifth_cursor.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
813
codex-rs/apply-patch/src/invocation.rs
Normal file
813
codex-rs/apply-patch/src/invocation.rs
Normal file
@@ -0,0 +1,813 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use tree_sitter::Parser;
|
||||
use tree_sitter::Query;
|
||||
use tree_sitter::QueryCursor;
|
||||
use tree_sitter::StreamingIterator;
|
||||
use tree_sitter_bash::LANGUAGE as BASH;
|
||||
|
||||
use crate::ApplyPatchAction;
|
||||
use crate::ApplyPatchArgs;
|
||||
use crate::ApplyPatchError;
|
||||
use crate::ApplyPatchFileChange;
|
||||
use crate::ApplyPatchFileUpdate;
|
||||
use crate::IoError;
|
||||
use crate::MaybeApplyPatchVerified;
|
||||
use crate::parser::Hunk;
|
||||
use crate::parser::ParseError;
|
||||
use crate::parser::parse_patch;
|
||||
use crate::unified_diff_from_chunks;
|
||||
use std::str::Utf8Error;
|
||||
use tree_sitter::LanguageError;
|
||||
|
||||
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ApplyPatchShell {
|
||||
Unix,
|
||||
PowerShell,
|
||||
Cmd,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum MaybeApplyPatch {
|
||||
Body(ApplyPatchArgs),
|
||||
ShellParseError(ExtractHeredocError),
|
||||
PatchParseError(ParseError),
|
||||
NotApplyPatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ExtractHeredocError {
|
||||
CommandDidNotStartWithApplyPatch,
|
||||
FailedToLoadBashGrammar(LanguageError),
|
||||
HeredocNotUtf8(Utf8Error),
|
||||
FailedToParsePatchIntoAst,
|
||||
FailedToFindHeredocBody,
|
||||
}
|
||||
|
||||
fn classify_shell_name(shell: &str) -> Option<String> {
|
||||
std::path::Path::new(shell)
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
|
||||
classify_shell_name(shell).and_then(|name| match name.as_str() {
|
||||
"bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
|
||||
"pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
|
||||
Some(ApplyPatchShell::PowerShell)
|
||||
}
|
||||
"cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn can_skip_flag(shell: &str, flag: &str) -> bool {
|
||||
classify_shell_name(shell).is_some_and(|name| {
|
||||
matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> {
|
||||
match argv {
|
||||
[shell, flag, script] => classify_shell(shell, flag).map(|shell_type| {
|
||||
let script = script.as_str();
|
||||
(shell_type, script)
|
||||
}),
|
||||
[shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => {
|
||||
classify_shell(shell, flag).map(|shell_type| {
|
||||
let script = script.as_str();
|
||||
(shell_type, script)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_apply_patch_from_shell(
|
||||
shell: ApplyPatchShell,
|
||||
script: &str,
|
||||
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
|
||||
match shell {
|
||||
ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
|
||||
extract_apply_patch_from_bash(script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make private once we remove tests in lib.rs
|
||||
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
match argv {
|
||||
// Direct invocation: apply_patch <patch>
|
||||
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
|
||||
Ok(source) => MaybeApplyPatch::Body(source),
|
||||
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
||||
},
|
||||
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
|
||||
_ => match parse_shell_script(argv) {
|
||||
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
|
||||
Ok((body, workdir)) => match parse_patch(&body) {
|
||||
Ok(mut source) => {
|
||||
source.workdir = workdir;
|
||||
MaybeApplyPatch::Body(source)
|
||||
}
|
||||
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
||||
},
|
||||
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
}
|
||||
Err(e) => MaybeApplyPatch::ShellParseError(e),
|
||||
},
|
||||
None => MaybeApplyPatch::NotApplyPatch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// cwd must be an absolute path so that we can resolve relative paths in the
|
||||
/// patch.
|
||||
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
|
||||
// Detect a raw patch body passed directly as the command or as the body of a shell
|
||||
// script. In these cases, report an explicit error rather than applying the patch.
|
||||
if let [body] = argv
|
||||
&& parse_patch(body).is_ok()
|
||||
{
|
||||
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
||||
}
|
||||
if let Some((_, script)) = parse_shell_script(argv)
|
||||
&& parse_patch(script).is_ok()
|
||||
{
|
||||
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
||||
}
|
||||
|
||||
match maybe_parse_apply_patch(argv) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs {
|
||||
patch,
|
||||
hunks,
|
||||
workdir,
|
||||
}) => {
|
||||
let effective_cwd = workdir
|
||||
.as_ref()
|
||||
.map(|dir| {
|
||||
let path = Path::new(dir);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| cwd.to_path_buf());
|
||||
let mut changes = HashMap::new();
|
||||
for hunk in hunks {
|
||||
let path = hunk.resolve_path(&effective_cwd);
|
||||
match hunk {
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
||||
}
|
||||
Hunk::DeleteFile { .. } => {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(
|
||||
ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read {}", path.display()),
|
||||
source: e,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
changes.insert(path, ApplyPatchFileChange::Delete { content });
|
||||
}
|
||||
Hunk::UpdateFile {
|
||||
move_path, chunks, ..
|
||||
} => {
|
||||
let ApplyPatchFileUpdate {
|
||||
unified_diff,
|
||||
content: contents,
|
||||
} = match unified_diff_from_chunks(&path, &chunks) {
|
||||
Ok(diff) => diff,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(e);
|
||||
}
|
||||
};
|
||||
changes.insert(
|
||||
path,
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path: move_path.map(|p| effective_cwd.join(p)),
|
||||
new_content: contents,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes,
|
||||
patch,
|
||||
cwd: effective_cwd,
|
||||
})
|
||||
}
|
||||
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
|
||||
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
|
||||
MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
|
||||
/// that invokes the apply_patch tool using a heredoc.
|
||||
///
|
||||
/// Supported top‑level forms (must be the only top‑level statement):
|
||||
/// - `apply_patch <<'EOF'\n...\nEOF`
|
||||
/// - `cd <path> && apply_patch <<'EOF'\n...\nEOF`
|
||||
///
|
||||
/// Notes about matching:
|
||||
/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the
|
||||
/// heredoc‑redirected statement is the only top‑level statement.
|
||||
/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`).
|
||||
/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted
|
||||
/// strings, no second argument).
|
||||
/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch`
|
||||
/// or `applypatch`.
|
||||
/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match.
|
||||
///
|
||||
/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or
|
||||
/// `(heredoc_body, None)` for the direct form. Errors are returned if the script
|
||||
/// cannot be parsed or does not match the allowed patterns.
|
||||
fn extract_apply_patch_from_bash(
|
||||
src: &str,
|
||||
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
|
||||
// This function uses a Tree-sitter query to recognize one of two
|
||||
// whole-script forms, each expressed as a single top-level statement:
|
||||
//
|
||||
// 1. apply_patch <<'EOF'\n...\nEOF
|
||||
// 2. cd <path> && apply_patch <<'EOF'\n...\nEOF
|
||||
//
|
||||
// Key ideas when reading the query:
|
||||
// - dots (`.`) between named nodes enforces adjacency among named children and
|
||||
// anchor to the start/end of the expression.
|
||||
// - we match a single redirected_statement directly under program with leading
|
||||
// and trailing anchors (`.`). This ensures it is the only top-level statement
|
||||
// (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match).
|
||||
//
|
||||
// Overall, we want to be conservative and only match the intended forms, as other
|
||||
// forms are likely to be model errors, or incorrectly interpreted by later code.
|
||||
//
|
||||
// If you're editing this query, it's helpful to start by creating a debugging binary
|
||||
// which will let you see the AST of an arbitrary bash script passed in, and optionally
|
||||
// also run an arbitrary query against the AST. This is useful for understanding
|
||||
// how tree-sitter parses the script and whether the query syntax is correct. Be sure
|
||||
// to test both positive and negative cases.
|
||||
static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
|
||||
let language = BASH.into();
|
||||
#[expect(clippy::expect_used)]
|
||||
Query::new(
|
||||
&language,
|
||||
r#"
|
||||
(
|
||||
program
|
||||
. (redirected_statement
|
||||
body: (command
|
||||
name: (command_name (word) @apply_name) .)
|
||||
(#any-of? @apply_name "apply_patch" "applypatch")
|
||||
redirect: (heredoc_redirect
|
||||
. (heredoc_start)
|
||||
. (heredoc_body) @heredoc
|
||||
. (heredoc_end)
|
||||
.))
|
||||
.)
|
||||
|
||||
(
|
||||
program
|
||||
. (redirected_statement
|
||||
body: (list
|
||||
. (command
|
||||
name: (command_name (word) @cd_name) .
|
||||
argument: [
|
||||
(word) @cd_path
|
||||
(string (string_content) @cd_path)
|
||||
(raw_string) @cd_raw_string
|
||||
] .)
|
||||
"&&"
|
||||
. (command
|
||||
name: (command_name (word) @apply_name))
|
||||
.)
|
||||
(#eq? @cd_name "cd")
|
||||
(#any-of? @apply_name "apply_patch" "applypatch")
|
||||
redirect: (heredoc_redirect
|
||||
. (heredoc_start)
|
||||
. (heredoc_body) @heredoc
|
||||
. (heredoc_end)
|
||||
.))
|
||||
.)
|
||||
"#,
|
||||
)
|
||||
.expect("valid bash query")
|
||||
});
|
||||
|
||||
let lang = BASH.into();
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(&lang)
|
||||
.map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
|
||||
let tree = parser
|
||||
.parse(src, None)
|
||||
.ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
|
||||
|
||||
let bytes = src.as_bytes();
|
||||
let root = tree.root_node();
|
||||
|
||||
let mut cursor = QueryCursor::new();
|
||||
let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
|
||||
while let Some(m) = matches.next() {
|
||||
let mut heredoc_text: Option<String> = None;
|
||||
let mut cd_path: Option<String> = None;
|
||||
|
||||
for capture in m.captures.iter() {
|
||||
let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
|
||||
match name {
|
||||
"heredoc" => {
|
||||
let text = capture
|
||||
.node
|
||||
.utf8_text(bytes)
|
||||
.map_err(ExtractHeredocError::HeredocNotUtf8)?
|
||||
.trim_end_matches('\n')
|
||||
.to_string();
|
||||
heredoc_text = Some(text);
|
||||
}
|
||||
"cd_path" => {
|
||||
let text = capture
|
||||
.node
|
||||
.utf8_text(bytes)
|
||||
.map_err(ExtractHeredocError::HeredocNotUtf8)?
|
||||
.to_string();
|
||||
cd_path = Some(text);
|
||||
}
|
||||
"cd_raw_string" => {
|
||||
let raw = capture
|
||||
.node
|
||||
.utf8_text(bytes)
|
||||
.map_err(ExtractHeredocError::HeredocNotUtf8)?;
|
||||
let trimmed = raw
|
||||
.strip_prefix('\'')
|
||||
.and_then(|s| s.strip_suffix('\''))
|
||||
.unwrap_or(raw);
|
||||
cd_path = Some(trimmed.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(heredoc) = heredoc_text {
|
||||
return Ok((heredoc, cd_path));
|
||||
}
|
||||
}
|
||||
|
||||
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use assert_matches::assert_matches;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::string::ToString;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Helper to construct a patch with the given body.
|
||||
fn wrap_patch(body: &str) -> String {
|
||||
format!("*** Begin Patch\n{body}\n*** End Patch")
|
||||
}
|
||||
|
||||
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
|
||||
strs.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
// Test helpers to reduce repetition when building bash -lc heredoc scripts
|
||||
fn args_bash(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["bash", "-lc", script])
|
||||
}
|
||||
|
||||
fn args_powershell(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_powershell_no_profile(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_pwsh(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_cmd(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["cmd.exe", "/c", script])
|
||||
}
|
||||
|
||||
fn heredoc_script(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
|
||||
)
|
||||
}
|
||||
|
||||
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
|
||||
)
|
||||
}
|
||||
|
||||
fn expected_single_add() -> Vec<Hunk> {
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir.as_deref(), expected_workdir);
|
||||
assert_eq!(hunks, expected_single_add());
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
||||
let args = args_bash(script);
|
||||
assert_match_args(args, expected_workdir);
|
||||
}
|
||||
|
||||
fn assert_not_match(script: &str) {
|
||||
let args = args_bash(script);
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch(&args),
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_single_arg_is_error() {
|
||||
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
|
||||
let args = vec![patch];
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_bash_script_is_error() {
|
||||
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
|
||||
let args = args_bash(script);
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal() {
|
||||
let args = strs_to_strings(&[
|
||||
"apply_patch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"applypatch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc() {
|
||||
assert_match(&heredoc_script(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_non_login_shell() {
|
||||
let script = heredoc_script("");
|
||||
let args = strs_to_strings(&["bash", "-c", &script]);
|
||||
assert_match_args(args, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
PATCH"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir, None);
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_powershell_heredoc_no_profile() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell_no_profile(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_pwsh_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_pwsh(&script), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_heredoc_with_cd() {
|
||||
let script = heredoc_script("cd foo && ");
|
||||
assert_match_args(args_cmd(&script), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_with_leading_cd() {
|
||||
assert_match(&heredoc_script("cd foo && "), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_with_semicolon_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo; "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_or_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar || "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_pipe_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar | "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_single_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_double_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_and_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("echo foo && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_with_arg_is_ignored() {
|
||||
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
|
||||
assert_not_match(script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_cd_then_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo && cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_two_args_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_then_apply_patch_then_extra_is_ignored() {
|
||||
let script = heredoc_script_ps("cd bar && ", " && echo done");
|
||||
assert_not_match(&script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_then_cd_and_apply_patch_is_ignored() {
|
||||
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
|
||||
assert_not_match(&heredoc_script("echo foo; cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_last_line_replacement() {
|
||||
// Replace the very last line of the file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("last.txt");
|
||||
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
foo
|
||||
bar
|
||||
-baz
|
||||
+BAZ
|
||||
"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let patch = parse_patch(&patch).unwrap();
|
||||
let chunks = match patch.hunks.as_slice() {
|
||||
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let expected_diff = r#"@@ -2,2 +2,2 @@
|
||||
bar
|
||||
-baz
|
||||
+BAZ
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "foo\nbar\nBAZ\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_insert_at_eof() {
|
||||
// Insert a new line at end‑of‑file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("insert.txt");
|
||||
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
+quux
|
||||
*** End of File
|
||||
"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let patch = parse_patch(&patch).unwrap();
|
||||
let chunks = match patch.hunks.as_slice() {
|
||||
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let expected_diff = r#"@@ -3 +3,2 @@
|
||||
baz
|
||||
+quux
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "foo\nbar\nbaz\nquux\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let relative_path = "source.txt";
|
||||
|
||||
// Note that we need this file to exist for the patch to be "verified"
|
||||
// and parsed correctly.
|
||||
let session_file_path = session_dir.path().join(relative_path);
|
||||
fs::write(&session_file_path, "session directory content\n").unwrap();
|
||||
|
||||
let argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
r#"*** Begin Patch
|
||||
*** Update File: source.txt
|
||||
@@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
*** End Patch"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
|
||||
// Verify the patch contents - as otherwise we may have pulled contents
|
||||
// from the wrong file (as we're using relative paths)
|
||||
assert_eq!(
|
||||
result,
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes: HashMap::from([(
|
||||
session_dir.path().join(relative_path),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: r#"@@ -1 +1 @@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
"#
|
||||
.to_string(),
|
||||
move_path: None,
|
||||
new_content: "updated session directory content\n".to_string(),
|
||||
},
|
||||
)]),
|
||||
patch: argv[1].clone(),
|
||||
cwd: session_dir.path().to_path_buf(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let worktree_rel = "alt";
|
||||
let worktree_dir = session_dir.path().join(worktree_rel);
|
||||
fs::create_dir_all(&worktree_dir).unwrap();
|
||||
|
||||
let source_name = "old.txt";
|
||||
let dest_name = "renamed.txt";
|
||||
let source_path = worktree_dir.join(source_name);
|
||||
fs::write(&source_path, "before\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {source_name}
|
||||
*** Move to: {dest_name}
|
||||
@@
|
||||
-before
|
||||
+after"#
|
||||
));
|
||||
|
||||
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
|
||||
let argv = vec!["bash".into(), "-lc".into(), shell_script];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
let action = match result {
|
||||
MaybeApplyPatchVerified::Body(action) => action,
|
||||
other => panic!("expected verified body, got {other:?}"),
|
||||
};
|
||||
|
||||
assert_eq!(action.cwd, worktree_dir);
|
||||
|
||||
let change = action
|
||||
.changes()
|
||||
.get(&worktree_dir.join(source_name))
|
||||
.expect("source file change present");
|
||||
|
||||
match change {
|
||||
ApplyPatchFileChange::Update { move_path, .. } => {
|
||||
assert_eq!(
|
||||
move_path.as_deref(),
|
||||
Some(worktree_dir.join(dest_name).as_path())
|
||||
);
|
||||
}
|
||||
other => panic!("expected update change, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod invocation;
|
||||
mod parser;
|
||||
mod seek_sequence;
|
||||
mod standalone_executable;
|
||||
@@ -5,8 +6,6 @@ mod standalone_executable;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::str::Utf8Error;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
@@ -17,27 +16,15 @@ use parser::UpdateFileChunk;
|
||||
pub use parser::parse_patch;
|
||||
use similar::TextDiff;
|
||||
use thiserror::Error;
|
||||
use tree_sitter::LanguageError;
|
||||
use tree_sitter::Parser;
|
||||
use tree_sitter::Query;
|
||||
use tree_sitter::QueryCursor;
|
||||
use tree_sitter::StreamingIterator;
|
||||
use tree_sitter_bash::LANGUAGE as BASH;
|
||||
|
||||
pub use invocation::maybe_parse_apply_patch_verified;
|
||||
pub use standalone_executable::main;
|
||||
|
||||
use crate::invocation::ExtractHeredocError;
|
||||
|
||||
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
|
||||
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
|
||||
|
||||
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ApplyPatchShell {
|
||||
Unix,
|
||||
PowerShell,
|
||||
Cmd,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ApplyPatchError {
|
||||
#[error(transparent)]
|
||||
@@ -86,14 +73,6 @@ impl PartialEq for IoError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum MaybeApplyPatch {
|
||||
Body(ApplyPatchArgs),
|
||||
ShellParseError(ExtractHeredocError),
|
||||
PatchParseError(ParseError),
|
||||
NotApplyPatch,
|
||||
}
|
||||
|
||||
/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
|
||||
/// parsed into hunks.
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -103,84 +82,6 @@ pub struct ApplyPatchArgs {
|
||||
pub workdir: Option<String>,
|
||||
}
|
||||
|
||||
fn classify_shell_name(shell: &str) -> Option<String> {
|
||||
std::path::Path::new(shell)
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
|
||||
classify_shell_name(shell).and_then(|name| match name.as_str() {
|
||||
"bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
|
||||
"pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
|
||||
Some(ApplyPatchShell::PowerShell)
|
||||
}
|
||||
"cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn can_skip_flag(shell: &str, flag: &str) -> bool {
|
||||
classify_shell_name(shell).is_some_and(|name| {
|
||||
matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> {
|
||||
match argv {
|
||||
[shell, flag, script] => classify_shell(shell, flag).map(|shell_type| {
|
||||
let script = script.as_str();
|
||||
(shell_type, script)
|
||||
}),
|
||||
[shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => {
|
||||
classify_shell(shell, flag).map(|shell_type| {
|
||||
let script = script.as_str();
|
||||
(shell_type, script)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_apply_patch_from_shell(
|
||||
shell: ApplyPatchShell,
|
||||
script: &str,
|
||||
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
|
||||
match shell {
|
||||
ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
|
||||
extract_apply_patch_from_bash(script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
match argv {
|
||||
// Direct invocation: apply_patch <patch>
|
||||
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
|
||||
Ok(source) => MaybeApplyPatch::Body(source),
|
||||
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
||||
},
|
||||
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
|
||||
_ => match parse_shell_script(argv) {
|
||||
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
|
||||
Ok((body, workdir)) => match parse_patch(&body) {
|
||||
Ok(mut source) => {
|
||||
source.workdir = workdir;
|
||||
MaybeApplyPatch::Body(source)
|
||||
}
|
||||
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
||||
},
|
||||
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
}
|
||||
Err(e) => MaybeApplyPatch::ShellParseError(e),
|
||||
},
|
||||
None => MaybeApplyPatch::NotApplyPatch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ApplyPatchFileChange {
|
||||
Add {
|
||||
@@ -269,256 +170,6 @@ impl ApplyPatchAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// cwd must be an absolute path so that we can resolve relative paths in the
|
||||
/// patch.
|
||||
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
|
||||
// Detect a raw patch body passed directly as the command or as the body of a shell
|
||||
// script. In these cases, report an explicit error rather than applying the patch.
|
||||
if let [body] = argv
|
||||
&& parse_patch(body).is_ok()
|
||||
{
|
||||
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
||||
}
|
||||
if let Some((_, script)) = parse_shell_script(argv)
|
||||
&& parse_patch(script).is_ok()
|
||||
{
|
||||
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
||||
}
|
||||
|
||||
match maybe_parse_apply_patch(argv) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs {
|
||||
patch,
|
||||
hunks,
|
||||
workdir,
|
||||
}) => {
|
||||
let effective_cwd = workdir
|
||||
.as_ref()
|
||||
.map(|dir| {
|
||||
let path = Path::new(dir);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| cwd.to_path_buf());
|
||||
let mut changes = HashMap::new();
|
||||
for hunk in hunks {
|
||||
let path = hunk.resolve_path(&effective_cwd);
|
||||
match hunk {
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
||||
}
|
||||
Hunk::DeleteFile { .. } => {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(
|
||||
ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read {}", path.display()),
|
||||
source: e,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
changes.insert(path, ApplyPatchFileChange::Delete { content });
|
||||
}
|
||||
Hunk::UpdateFile {
|
||||
move_path, chunks, ..
|
||||
} => {
|
||||
let ApplyPatchFileUpdate {
|
||||
unified_diff,
|
||||
content: contents,
|
||||
} = match unified_diff_from_chunks(&path, &chunks) {
|
||||
Ok(diff) => diff,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(e);
|
||||
}
|
||||
};
|
||||
changes.insert(
|
||||
path,
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path: move_path.map(|p| effective_cwd.join(p)),
|
||||
new_content: contents,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes,
|
||||
patch,
|
||||
cwd: effective_cwd,
|
||||
})
|
||||
}
|
||||
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
|
||||
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
|
||||
MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
|
||||
/// that invokes the apply_patch tool using a heredoc.
|
||||
///
|
||||
/// Supported top‑level forms (must be the only top‑level statement):
|
||||
/// - `apply_patch <<'EOF'\n...\nEOF`
|
||||
/// - `cd <path> && apply_patch <<'EOF'\n...\nEOF`
|
||||
///
|
||||
/// Notes about matching:
|
||||
/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the
|
||||
/// heredoc‑redirected statement is the only top‑level statement.
|
||||
/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`).
|
||||
/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted
|
||||
/// strings, no second argument).
|
||||
/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch`
|
||||
/// or `applypatch`.
|
||||
/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match.
|
||||
///
|
||||
/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or
|
||||
/// `(heredoc_body, None)` for the direct form. Errors are returned if the script
|
||||
/// cannot be parsed or does not match the allowed patterns.
|
||||
fn extract_apply_patch_from_bash(
|
||||
src: &str,
|
||||
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
|
||||
// This function uses a Tree-sitter query to recognize one of two
|
||||
// whole-script forms, each expressed as a single top-level statement:
|
||||
//
|
||||
// 1. apply_patch <<'EOF'\n...\nEOF
|
||||
// 2. cd <path> && apply_patch <<'EOF'\n...\nEOF
|
||||
//
|
||||
// Key ideas when reading the query:
|
||||
// - dots (`.`) between named nodes enforces adjacency among named children and
|
||||
// anchor to the start/end of the expression.
|
||||
// - we match a single redirected_statement directly under program with leading
|
||||
// and trailing anchors (`.`). This ensures it is the only top-level statement
|
||||
// (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match).
|
||||
//
|
||||
// Overall, we want to be conservative and only match the intended forms, as other
|
||||
// forms are likely to be model errors, or incorrectly interpreted by later code.
|
||||
//
|
||||
// If you're editing this query, it's helpful to start by creating a debugging binary
|
||||
// which will let you see the AST of an arbitrary bash script passed in, and optionally
|
||||
// also run an arbitrary query against the AST. This is useful for understanding
|
||||
// how tree-sitter parses the script and whether the query syntax is correct. Be sure
|
||||
// to test both positive and negative cases.
|
||||
static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
|
||||
let language = BASH.into();
|
||||
#[expect(clippy::expect_used)]
|
||||
Query::new(
|
||||
&language,
|
||||
r#"
|
||||
(
|
||||
program
|
||||
. (redirected_statement
|
||||
body: (command
|
||||
name: (command_name (word) @apply_name) .)
|
||||
(#any-of? @apply_name "apply_patch" "applypatch")
|
||||
redirect: (heredoc_redirect
|
||||
. (heredoc_start)
|
||||
. (heredoc_body) @heredoc
|
||||
. (heredoc_end)
|
||||
.))
|
||||
.)
|
||||
|
||||
(
|
||||
program
|
||||
. (redirected_statement
|
||||
body: (list
|
||||
. (command
|
||||
name: (command_name (word) @cd_name) .
|
||||
argument: [
|
||||
(word) @cd_path
|
||||
(string (string_content) @cd_path)
|
||||
(raw_string) @cd_raw_string
|
||||
] .)
|
||||
"&&"
|
||||
. (command
|
||||
name: (command_name (word) @apply_name))
|
||||
.)
|
||||
(#eq? @cd_name "cd")
|
||||
(#any-of? @apply_name "apply_patch" "applypatch")
|
||||
redirect: (heredoc_redirect
|
||||
. (heredoc_start)
|
||||
. (heredoc_body) @heredoc
|
||||
. (heredoc_end)
|
||||
.))
|
||||
.)
|
||||
"#,
|
||||
)
|
||||
.expect("valid bash query")
|
||||
});
|
||||
|
||||
let lang = BASH.into();
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(&lang)
|
||||
.map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
|
||||
let tree = parser
|
||||
.parse(src, None)
|
||||
.ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
|
||||
|
||||
let bytes = src.as_bytes();
|
||||
let root = tree.root_node();
|
||||
|
||||
let mut cursor = QueryCursor::new();
|
||||
let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
|
||||
while let Some(m) = matches.next() {
|
||||
let mut heredoc_text: Option<String> = None;
|
||||
let mut cd_path: Option<String> = None;
|
||||
|
||||
for capture in m.captures.iter() {
|
||||
let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
|
||||
match name {
|
||||
"heredoc" => {
|
||||
let text = capture
|
||||
.node
|
||||
.utf8_text(bytes)
|
||||
.map_err(ExtractHeredocError::HeredocNotUtf8)?
|
||||
.trim_end_matches('\n')
|
||||
.to_string();
|
||||
heredoc_text = Some(text);
|
||||
}
|
||||
"cd_path" => {
|
||||
let text = capture
|
||||
.node
|
||||
.utf8_text(bytes)
|
||||
.map_err(ExtractHeredocError::HeredocNotUtf8)?
|
||||
.to_string();
|
||||
cd_path = Some(text);
|
||||
}
|
||||
"cd_raw_string" => {
|
||||
let raw = capture
|
||||
.node
|
||||
.utf8_text(bytes)
|
||||
.map_err(ExtractHeredocError::HeredocNotUtf8)?;
|
||||
let trimmed = raw
|
||||
.strip_prefix('\'')
|
||||
.and_then(|s| s.strip_suffix('\''))
|
||||
.unwrap_or(raw);
|
||||
cd_path = Some(trimmed.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(heredoc) = heredoc_text {
|
||||
return Ok((heredoc, cd_path));
|
||||
}
|
||||
}
|
||||
|
||||
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ExtractHeredocError {
|
||||
CommandDidNotStartWithApplyPatch,
|
||||
FailedToLoadBashGrammar(LanguageError),
|
||||
HeredocNotUtf8(Utf8Error),
|
||||
FailedToParsePatchIntoAst,
|
||||
FailedToFindHeredocBody,
|
||||
}
|
||||
|
||||
/// Applies the patch and prints the result to stdout/stderr.
|
||||
pub fn apply_patch(
|
||||
patch: &str,
|
||||
@@ -894,7 +545,6 @@ pub fn print_summary(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use assert_matches::assert_matches;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::string::ToString;
|
||||
@@ -905,270 +555,6 @@ mod tests {
|
||||
format!("*** Begin Patch\n{body}\n*** End Patch")
|
||||
}
|
||||
|
||||
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
|
||||
strs.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
// Test helpers to reduce repetition when building bash -lc heredoc scripts
|
||||
fn args_bash(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["bash", "-lc", script])
|
||||
}
|
||||
|
||||
fn args_powershell(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_powershell_no_profile(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_pwsh(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_cmd(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["cmd.exe", "/c", script])
|
||||
}
|
||||
|
||||
fn heredoc_script(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
|
||||
)
|
||||
}
|
||||
|
||||
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
|
||||
)
|
||||
}
|
||||
|
||||
fn expected_single_add() -> Vec<Hunk> {
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir.as_deref(), expected_workdir);
|
||||
assert_eq!(hunks, expected_single_add());
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
||||
let args = args_bash(script);
|
||||
assert_match_args(args, expected_workdir);
|
||||
}
|
||||
|
||||
fn assert_not_match(script: &str) {
|
||||
let args = args_bash(script);
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch(&args),
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_single_arg_is_error() {
|
||||
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
|
||||
let args = vec![patch];
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_bash_script_is_error() {
|
||||
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
|
||||
let args = args_bash(script);
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal() {
|
||||
let args = strs_to_strings(&[
|
||||
"apply_patch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"applypatch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc() {
|
||||
assert_match(&heredoc_script(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_non_login_shell() {
|
||||
let script = heredoc_script("");
|
||||
let args = strs_to_strings(&["bash", "-c", &script]);
|
||||
assert_match_args(args, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
PATCH"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir, None);
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_powershell_heredoc_no_profile() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell_no_profile(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_pwsh_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_pwsh(&script), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_heredoc_with_cd() {
|
||||
let script = heredoc_script("cd foo && ");
|
||||
assert_match_args(args_cmd(&script), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_with_leading_cd() {
|
||||
assert_match(&heredoc_script("cd foo && "), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_with_semicolon_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo; "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_or_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar || "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_pipe_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar | "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_single_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_double_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_and_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("echo foo && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_with_arg_is_ignored() {
|
||||
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
|
||||
assert_not_match(script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_cd_then_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo && cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_two_args_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_then_apply_patch_then_extra_is_ignored() {
|
||||
let script = heredoc_script_ps("cd bar && ", " && echo done");
|
||||
assert_not_match(&script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_then_cd_and_apply_patch_is_ignored() {
|
||||
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
|
||||
assert_not_match(&heredoc_script("echo foo; cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_file_hunk_creates_file_with_contents() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -1657,99 +1043,6 @@ g
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let relative_path = "source.txt";
|
||||
|
||||
// Note that we need this file to exist for the patch to be "verified"
|
||||
// and parsed correctly.
|
||||
let session_file_path = session_dir.path().join(relative_path);
|
||||
fs::write(&session_file_path, "session directory content\n").unwrap();
|
||||
|
||||
let argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
r#"*** Begin Patch
|
||||
*** Update File: source.txt
|
||||
@@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
*** End Patch"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
|
||||
// Verify the patch contents - as otherwise we may have pulled contents
|
||||
// from the wrong file (as we're using relative paths)
|
||||
assert_eq!(
|
||||
result,
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes: HashMap::from([(
|
||||
session_dir.path().join(relative_path),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: r#"@@ -1 +1 @@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
"#
|
||||
.to_string(),
|
||||
move_path: None,
|
||||
new_content: "updated session directory content\n".to_string(),
|
||||
},
|
||||
)]),
|
||||
patch: argv[1].clone(),
|
||||
cwd: session_dir.path().to_path_buf(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let worktree_rel = "alt";
|
||||
let worktree_dir = session_dir.path().join(worktree_rel);
|
||||
fs::create_dir_all(&worktree_dir).unwrap();
|
||||
|
||||
let source_name = "old.txt";
|
||||
let dest_name = "renamed.txt";
|
||||
let source_path = worktree_dir.join(source_name);
|
||||
fs::write(&source_path, "before\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {source_name}
|
||||
*** Move to: {dest_name}
|
||||
@@
|
||||
-before
|
||||
+after"#
|
||||
));
|
||||
|
||||
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
|
||||
let argv = vec!["bash".into(), "-lc".into(), shell_script];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
let action = match result {
|
||||
MaybeApplyPatchVerified::Body(action) => action,
|
||||
other => panic!("expected verified body, got {other:?}"),
|
||||
};
|
||||
|
||||
assert_eq!(action.cwd, worktree_dir);
|
||||
|
||||
let change = action
|
||||
.changes()
|
||||
.get(&worktree_dir.join(source_name))
|
||||
.expect("source file change present");
|
||||
|
||||
match change {
|
||||
ApplyPatchFileChange::Update { move_path, .. } => {
|
||||
assert_eq!(
|
||||
move_path.as_deref(),
|
||||
Some(worktree_dir.join(dest_name).as_path())
|
||||
);
|
||||
}
|
||||
other => panic!("expected update change, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_fails_on_write_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
stable
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
stable
|
||||
@@ -0,0 +1 @@
|
||||
stable
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
stable
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
stable
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
stable
|
||||
@@ -0,0 +1 @@
|
||||
stable
|
||||
@@ -0,0 +1 @@
|
||||
stable
|
||||
@@ -0,0 +1 @@
|
||||
stable
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/input/dir/foo.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/input/dir/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
stable
|
||||
@@ -0,0 +1 @@
|
||||
stable
|
||||
1
codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/input/foo.txt
vendored
Normal file
1
codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/input/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
stable
|
||||
3
codex-rs/apply-patch/tests/fixtures/scenarios/019_unicode_simple/expected/foo.txt
vendored
Normal file
3
codex-rs/apply-patch/tests/fixtures/scenarios/019_unicode_simple/expected/foo.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
line1
|
||||
naïve café ✅
|
||||
line3
|
||||
3
codex-rs/apply-patch/tests/fixtures/scenarios/019_unicode_simple/input/foo.txt
vendored
Normal file
3
codex-rs/apply-patch/tests/fixtures/scenarios/019_unicode_simple/input/foo.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
line1
|
||||
naïve café
|
||||
line3
|
||||
7
codex-rs/apply-patch/tests/fixtures/scenarios/019_unicode_simple/patch.txt
vendored
Normal file
7
codex-rs/apply-patch/tests/fixtures/scenarios/019_unicode_simple/patch.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
*** Begin Patch
|
||||
*** Update File: foo.txt
|
||||
@@
|
||||
line1
|
||||
-naïve café
|
||||
+naïve café ✅
|
||||
*** End Patch
|
||||
@@ -30,6 +30,7 @@ codex-exec = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-mcp-server = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-process-hardening = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-responses-api-proxy = { workspace = true }
|
||||
|
||||
@@ -130,7 +130,11 @@ async fn run_command_under_sandbox(
|
||||
let sandbox_policy_cwd = cwd.clone();
|
||||
|
||||
let stdio_policy = StdioPolicy::Inherit;
|
||||
let env = create_env(&config.shell_environment_policy);
|
||||
let env = create_env(
|
||||
&config.shell_environment_policy,
|
||||
&config.sandbox_policy,
|
||||
&config.network_proxy,
|
||||
);
|
||||
|
||||
// Special-case Windows sandbox: execute and exit the process to emulate inherited stdio.
|
||||
if let SandboxType::Windows = sandbox_type {
|
||||
|
||||
@@ -21,6 +21,7 @@ use codex_exec::Cli as ExecCli;
|
||||
use codex_exec::Command as ExecCommand;
|
||||
use codex_exec::ReviewArgs;
|
||||
use codex_execpolicy::ExecPolicyCheckCommand;
|
||||
use codex_network_proxy::Args as NetworkProxyArgs;
|
||||
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
|
||||
use codex_tui::AppExitInfo;
|
||||
use codex_tui::Cli as TuiCli;
|
||||
@@ -98,6 +99,9 @@ enum Subcommand {
|
||||
/// [experimental] Run the app server or related tooling.
|
||||
AppServer(AppServerCommand),
|
||||
|
||||
/// Run the Codex network proxy.
|
||||
Proxy(NetworkProxyArgs),
|
||||
|
||||
/// Generate shell completion scripts.
|
||||
Completion(CompletionCommand),
|
||||
|
||||
@@ -410,7 +414,7 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str {
|
||||
use codex_core::features::Stage;
|
||||
match stage {
|
||||
Stage::Experimental => "experimental",
|
||||
Stage::Beta => "beta",
|
||||
Stage::Beta { .. } => "beta",
|
||||
Stage::Stable => "stable",
|
||||
Stage::Deprecated => "deprecated",
|
||||
Stage::Removed => "removed",
|
||||
@@ -469,6 +473,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
);
|
||||
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
||||
}
|
||||
Some(Subcommand::Proxy(proxy_args)) => {
|
||||
codex_network_proxy::run_main(proxy_args).await?;
|
||||
}
|
||||
Some(Subcommand::McpServer) => {
|
||||
codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct ResponsesOptions {
|
||||
pub store_override: Option<bool>,
|
||||
pub conversation_id: Option<String>,
|
||||
pub session_source: Option<SessionSource>,
|
||||
pub extra_headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
|
||||
@@ -58,7 +59,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
|
||||
self.stream(request.body, request.headers).await
|
||||
}
|
||||
|
||||
#[instrument(skip_all, err)]
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
pub async fn stream_prompt(
|
||||
&self,
|
||||
model: &str,
|
||||
@@ -73,6 +74,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
|
||||
store_override,
|
||||
conversation_id,
|
||||
session_source,
|
||||
extra_headers,
|
||||
} = options;
|
||||
|
||||
let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input)
|
||||
@@ -85,6 +87,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
|
||||
.conversation(conversation_id)
|
||||
.session_source(session_source)
|
||||
.store_override(store_override)
|
||||
.extra_headers(extra_headers)
|
||||
.build(self.streaming.provider())?;
|
||||
|
||||
self.stream_request(request).await
|
||||
|
||||
@@ -181,7 +181,7 @@ mod tests {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
use opentelemetry_sdk::trace::SdkTracerProvider;
|
||||
use tracing::info_span;
|
||||
use tracing::trace_span;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
@@ -195,7 +195,7 @@ mod tests {
|
||||
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
let _guard = subscriber.set_default();
|
||||
|
||||
let span = info_span!("client_request");
|
||||
let span = trace_span!("client_request");
|
||||
let _entered = span.enter();
|
||||
let span_context = span.context().span().span_context().clone();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", model.to_string()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.to_string()),
|
||||
("approval", config.approval_policy.value().to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses {
|
||||
|
||||
395
codex-rs/core/models.json
Normal file
395
codex-rs/core/models.json
Normal file
File diff suppressed because one or more lines are too long
@@ -45,6 +45,7 @@ use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::features::FEATURES;
|
||||
use crate::flags::CODEX_RS_SSE_FIXTURE;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
@@ -261,6 +262,7 @@ impl ModelClient {
|
||||
store_override: None,
|
||||
conversation_id: Some(conversation_id.clone()),
|
||||
session_source: Some(session_source.clone()),
|
||||
extra_headers: beta_feature_headers(&self.config),
|
||||
};
|
||||
|
||||
let stream_result = client
|
||||
@@ -396,6 +398,27 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec<Value
|
||||
}
|
||||
}
|
||||
|
||||
fn beta_feature_headers(config: &Config) -> ApiHeaderMap {
|
||||
let enabled = FEATURES
|
||||
.iter()
|
||||
.filter_map(|spec| {
|
||||
if spec.stage.beta_menu_description().is_some() && config.features.enabled(spec.id) {
|
||||
Some(spec.key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let value = enabled.join(",");
|
||||
let mut headers = ApiHeaderMap::new();
|
||||
if !value.is_empty()
|
||||
&& let Ok(header_value) = HeaderValue::from_str(value.as_str())
|
||||
{
|
||||
headers.insert("x-codex-beta-features", header_value);
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn map_response_stream<S>(api_stream: S, otel_manager: OtelManager) -> ResponseStream
|
||||
where
|
||||
S: futures::Stream<Item = std::result::Result<ResponseEvent, ApiError>>
|
||||
|
||||
@@ -66,8 +66,8 @@ use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::field;
|
||||
use tracing::info;
|
||||
use tracing::info_span;
|
||||
use tracing::instrument;
|
||||
use tracing::trace_span;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ModelProviderInfo;
|
||||
@@ -77,7 +77,11 @@ use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::compact::collect_user_messages;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintError;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use crate::config::types::NetworkProxyConfig;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
@@ -96,6 +100,7 @@ use crate::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::BackgroundEventEvent;
|
||||
use crate::protocol::DeprecationNoticeEvent;
|
||||
use crate::protocol::ErrorEvent;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecApprovalRequestEvent;
|
||||
@@ -260,7 +265,7 @@ impl Codex {
|
||||
user_instructions,
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -364,6 +369,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) approval_policy: AskForApproval,
|
||||
pub(crate) sandbox_policy: SandboxPolicy,
|
||||
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
|
||||
pub(crate) network_proxy: NetworkProxyConfig,
|
||||
pub(crate) tools_config: ToolsConfig,
|
||||
pub(crate) ghost_snapshot: GhostSnapshotConfig,
|
||||
pub(crate) final_output_json_schema: Option<Value>,
|
||||
@@ -387,6 +393,7 @@ impl TurnContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SessionConfiguration {
|
||||
/// Provider identifier ("openai", "openrouter", ...).
|
||||
@@ -411,7 +418,7 @@ pub(crate) struct SessionConfiguration {
|
||||
compact_prompt: Option<String>,
|
||||
|
||||
/// When to escalate for approval for execution
|
||||
approval_policy: AskForApproval,
|
||||
approval_policy: Constrained<AskForApproval>,
|
||||
/// How to sandbox commands executed in the system
|
||||
sandbox_policy: SandboxPolicy,
|
||||
|
||||
@@ -434,7 +441,7 @@ pub(crate) struct SessionConfiguration {
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> Self {
|
||||
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult<Self> {
|
||||
let mut next_configuration = self.clone();
|
||||
if let Some(model) = updates.model.clone() {
|
||||
next_configuration.model = model;
|
||||
@@ -446,7 +453,7 @@ impl SessionConfiguration {
|
||||
next_configuration.model_reasoning_summary = summary;
|
||||
}
|
||||
if let Some(approval_policy) = updates.approval_policy {
|
||||
next_configuration.approval_policy = approval_policy;
|
||||
next_configuration.approval_policy.set(approval_policy)?;
|
||||
}
|
||||
if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
|
||||
next_configuration.sandbox_policy = sandbox_policy;
|
||||
@@ -454,7 +461,7 @@ impl SessionConfiguration {
|
||||
if let Some(cwd) = updates.cwd.clone() {
|
||||
next_configuration.cwd = cwd;
|
||||
}
|
||||
next_configuration
|
||||
Ok(next_configuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,9 +530,10 @@ impl Session {
|
||||
base_instructions: session_configuration.base_instructions.clone(),
|
||||
compact_prompt: session_configuration.compact_prompt.clone(),
|
||||
user_instructions: session_configuration.user_instructions.clone(),
|
||||
approval_policy: session_configuration.approval_policy,
|
||||
approval_policy: session_configuration.approval_policy.value(),
|
||||
sandbox_policy: session_configuration.sandbox_policy.clone(),
|
||||
shell_environment_policy: per_turn_config.shell_environment_policy.clone(),
|
||||
network_proxy: per_turn_config.network_proxy.clone(),
|
||||
tools_config,
|
||||
ghost_snapshot: per_turn_config.ghost_snapshot.clone(),
|
||||
final_output_json_schema: None,
|
||||
@@ -640,7 +648,7 @@ impl Session {
|
||||
config.model_reasoning_summary,
|
||||
config.model_context_window,
|
||||
config.model_auto_compact_token_limit,
|
||||
config.approval_policy,
|
||||
config.approval_policy.value(),
|
||||
config.sandbox_policy.clone(),
|
||||
config.mcp_servers.keys().map(String::as_str).collect(),
|
||||
config.active_profile.clone(),
|
||||
@@ -690,7 +698,7 @@ impl Session {
|
||||
session_id: conversation_id,
|
||||
model: session_configuration.model.clone(),
|
||||
model_provider_id: config.model_provider_id.clone(),
|
||||
approval_policy: session_configuration.approval_policy,
|
||||
approval_policy: session_configuration.approval_policy.value(),
|
||||
sandbox_policy: session_configuration.sandbox_policy.clone(),
|
||||
cwd: session_configuration.cwd.clone(),
|
||||
reasoning_effort: session_configuration.model_reasoning_effort,
|
||||
@@ -729,6 +737,30 @@ impl Session {
|
||||
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
|
||||
sess.record_initial_history(initial_history).await;
|
||||
|
||||
if sess.enabled(Feature::Skills) {
|
||||
let mut rx = sess
|
||||
.services
|
||||
.skills_manager
|
||||
.subscribe_skills_update_notifications();
|
||||
let sess = Arc::downgrade(&sess);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(()) => {
|
||||
let Some(sess) = sess.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let turn_context = sess.new_default_turn().await;
|
||||
sess.send_event(turn_context.as_ref(), EventMsg::SkillsUpdateAvailable)
|
||||
.await;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(sess)
|
||||
}
|
||||
|
||||
@@ -762,7 +794,7 @@ impl Session {
|
||||
}
|
||||
|
||||
async fn record_initial_history(&self, conversation_history: InitialHistory) {
|
||||
let turn_context = self.new_turn(SessionSettingsUpdate::default()).await;
|
||||
let turn_context = self.new_default_turn().await;
|
||||
match conversation_history {
|
||||
InitialHistory::New => {
|
||||
// Build and record initial items (user instructions + environment context)
|
||||
@@ -821,30 +853,76 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) {
|
||||
pub(crate) async fn update_settings(
|
||||
&self,
|
||||
updates: SessionSettingsUpdate,
|
||||
) -> ConstraintResult<()> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
state.session_configuration = state.session_configuration.apply(&updates);
|
||||
}
|
||||
|
||||
pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc<TurnContext> {
|
||||
let sub_id = self.next_internal_sub_id();
|
||||
self.new_turn_with_sub_id(sub_id, updates).await
|
||||
match state.session_configuration.apply(&updates) {
|
||||
Ok(updated) => {
|
||||
state.session_configuration = updated;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
let wrapped = ConstraintError {
|
||||
message: format!("Could not update config: {err}"),
|
||||
};
|
||||
warn!(%wrapped, "rejected session settings update");
|
||||
Err(wrapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn new_turn_with_sub_id(
|
||||
&self,
|
||||
sub_id: String,
|
||||
updates: SessionSettingsUpdate,
|
||||
) -> Arc<TurnContext> {
|
||||
) -> ConstraintResult<Arc<TurnContext>> {
|
||||
let (session_configuration, sandbox_policy_changed) = {
|
||||
let mut state = self.state.lock().await;
|
||||
let session_configuration = state.session_configuration.clone().apply(&updates);
|
||||
let sandbox_policy_changed =
|
||||
state.session_configuration.sandbox_policy != session_configuration.sandbox_policy;
|
||||
state.session_configuration = session_configuration.clone();
|
||||
(session_configuration, sandbox_policy_changed)
|
||||
match state.session_configuration.clone().apply(&updates) {
|
||||
Ok(next) => {
|
||||
let sandbox_policy_changed =
|
||||
state.session_configuration.sandbox_policy != next.sandbox_policy;
|
||||
state.session_configuration = next.clone();
|
||||
(next, sandbox_policy_changed)
|
||||
}
|
||||
Err(err) => {
|
||||
drop(state);
|
||||
let wrapped = ConstraintError {
|
||||
message: format!("Could not update config: {err}"),
|
||||
};
|
||||
self.send_event_raw(Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: wrapped.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return Err(wrapped);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(self
|
||||
.new_turn_from_configuration(
|
||||
sub_id,
|
||||
session_configuration,
|
||||
updates.final_output_json_schema,
|
||||
sandbox_policy_changed,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn new_turn_from_configuration(
|
||||
&self,
|
||||
sub_id: String,
|
||||
session_configuration: SessionConfiguration,
|
||||
final_output_json_schema: Option<Option<Value>>,
|
||||
sandbox_policy_changed: bool,
|
||||
) -> Arc<TurnContext> {
|
||||
let per_turn_config = Self::build_per_turn_config(&session_configuration);
|
||||
|
||||
if sandbox_policy_changed {
|
||||
@@ -880,12 +958,26 @@ impl Session {
|
||||
self.conversation_id,
|
||||
sub_id,
|
||||
);
|
||||
if let Some(final_schema) = updates.final_output_json_schema {
|
||||
if let Some(final_schema) = final_output_json_schema {
|
||||
turn_context.final_output_json_schema = final_schema;
|
||||
}
|
||||
Arc::new(turn_context)
|
||||
}
|
||||
|
||||
pub(crate) async fn new_default_turn(&self) -> Arc<TurnContext> {
|
||||
self.new_default_turn_with_sub_id(self.next_internal_sub_id())
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
|
||||
let session_configuration = {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.clone()
|
||||
};
|
||||
self.new_turn_from_configuration(sub_id, session_configuration, None, false)
|
||||
.await
|
||||
}
|
||||
|
||||
fn build_environment_update_item(
|
||||
&self,
|
||||
previous: Option<&Arc<TurnContext>>,
|
||||
@@ -1010,6 +1102,7 @@ impl Session {
|
||||
cwd: PathBuf,
|
||||
reason: Option<String>,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
network_preflight_only: bool,
|
||||
) -> ReviewDecision {
|
||||
let sub_id = turn_context.sub_id.clone();
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
@@ -1038,6 +1131,7 @@ impl Session {
|
||||
reason,
|
||||
proposed_execpolicy_amendment,
|
||||
parsed_cmd,
|
||||
network_preflight_only,
|
||||
});
|
||||
self.send_event(turn_context, event).await;
|
||||
rx_approve.await.unwrap_or_default()
|
||||
@@ -1529,8 +1623,7 @@ impl Session {
|
||||
|
||||
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
|
||||
// Seed with context in case there is an OverrideTurnContext first.
|
||||
let mut previous_context: Option<Arc<TurnContext>> =
|
||||
Some(sess.new_turn(SessionSettingsUpdate::default()).await);
|
||||
let mut previous_context: Option<Arc<TurnContext>> = Some(sess.new_default_turn().await);
|
||||
|
||||
// To break out of this loop, send Op::Shutdown.
|
||||
while let Ok(sub) = rx_sub.recv().await {
|
||||
@@ -1549,6 +1642,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
} => {
|
||||
handlers::override_turn_context(
|
||||
&sess,
|
||||
sub.id.clone(),
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
approval_policy,
|
||||
@@ -1571,6 +1665,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::PatchApproval { id, decision } => {
|
||||
handlers::patch_approval(&sess, id, decision).await;
|
||||
}
|
||||
Op::NetworkApprovalCache { host, decision } => {
|
||||
handlers::network_approval_cache(&sess, host, decision).await;
|
||||
}
|
||||
Op::AddToHistory { text } => {
|
||||
handlers::add_to_history(&sess, &config, text).await;
|
||||
}
|
||||
@@ -1584,8 +1681,8 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
}
|
||||
Op::ListSkills { cwds } => {
|
||||
handlers::list_skills(&sess, sub.id.clone(), cwds).await;
|
||||
Op::ListSkills { cwds, force_reload } => {
|
||||
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
|
||||
}
|
||||
Op::Undo => {
|
||||
handlers::undo(&sess, sub.id.clone()).await;
|
||||
@@ -1634,6 +1731,7 @@ mod handlers {
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
use crate::network_proxy;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
use crate::tasks::CompactTask;
|
||||
use crate::tasks::RegularTask;
|
||||
@@ -1666,8 +1764,21 @@ mod handlers {
|
||||
sess.interrupt_task().await;
|
||||
}
|
||||
|
||||
pub async fn override_turn_context(sess: &Session, updates: SessionSettingsUpdate) {
|
||||
sess.update_settings(updates).await;
|
||||
pub async fn override_turn_context(
|
||||
sess: &Session,
|
||||
sub_id: String,
|
||||
updates: SessionSettingsUpdate,
|
||||
) {
|
||||
if let Err(err) = sess.update_settings(updates).await {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: err.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_input_or_turn(
|
||||
@@ -1702,7 +1813,10 @@ mod handlers {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let current_context = sess.new_turn_with_sub_id(sub_id, updates).await;
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
|
||||
// new_turn_with_sub_id already emits the error event.
|
||||
return;
|
||||
};
|
||||
current_context
|
||||
.client
|
||||
.get_otel_manager()
|
||||
@@ -1729,9 +1843,7 @@ mod handlers {
|
||||
command: String,
|
||||
previous_context: &mut Option<Arc<TurnContext>>,
|
||||
) {
|
||||
let turn_context = sess
|
||||
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
|
||||
.await;
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
sess.spawn_task(
|
||||
Arc::clone(&turn_context),
|
||||
Vec::new(),
|
||||
@@ -1803,6 +1915,21 @@ mod handlers {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn network_approval_cache(
|
||||
sess: &Arc<Session>,
|
||||
host: String,
|
||||
decision: ReviewDecision,
|
||||
) {
|
||||
if !matches!(
|
||||
decision,
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut store = sess.services.tool_approvals.lock().await;
|
||||
network_proxy::cache_network_approval(&mut store, &host, decision);
|
||||
}
|
||||
|
||||
pub async fn add_to_history(sess: &Arc<Session>, config: &Arc<Config>, text: String) {
|
||||
let id = sess.conversation_id;
|
||||
let config = Arc::clone(config);
|
||||
@@ -1885,7 +2012,12 @@ mod handlers {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec<PathBuf>) {
|
||||
pub async fn list_skills(
|
||||
sess: &Session,
|
||||
sub_id: String,
|
||||
cwds: Vec<PathBuf>,
|
||||
force_reload: bool,
|
||||
) {
|
||||
let cwds = if cwds.is_empty() {
|
||||
let state = sess.state.lock().await;
|
||||
vec![state.session_configuration.cwd.clone()]
|
||||
@@ -1896,7 +2028,7 @@ mod handlers {
|
||||
let skills_manager = &sess.services.skills_manager;
|
||||
cwds.into_iter()
|
||||
.map(|cwd| {
|
||||
let outcome = skills_manager.skills_for_cwd(&cwd);
|
||||
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let skills = super::skills_to_info(&outcome.skills);
|
||||
SkillsListEntry {
|
||||
@@ -1923,17 +2055,13 @@ mod handlers {
|
||||
}
|
||||
|
||||
pub async fn undo(sess: &Arc<Session>, sub_id: String) {
|
||||
let turn_context = sess
|
||||
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
|
||||
.await;
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
sess.spawn_task(turn_context, Vec::new(), UndoTask::new())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
|
||||
let turn_context = sess
|
||||
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
|
||||
.await;
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
|
||||
sess.spawn_task(
|
||||
Arc::clone(&turn_context),
|
||||
@@ -1987,9 +2115,7 @@ mod handlers {
|
||||
sub_id: String,
|
||||
review_request: ReviewRequest,
|
||||
) {
|
||||
let turn_context = sess
|
||||
.new_turn_with_sub_id(sub_id.clone(), SessionSettingsUpdate::default())
|
||||
.await;
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
|
||||
match resolve_review_request(review_request, config.cwd.as_path()) {
|
||||
Ok(resolved) => {
|
||||
spawn_review_thread(
|
||||
@@ -2081,6 +2207,7 @@ async fn spawn_review_thread(
|
||||
approval_policy: parent_turn_context.approval_policy,
|
||||
sandbox_policy: parent_turn_context.sandbox_policy.clone(),
|
||||
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),
|
||||
network_proxy: parent_turn_context.network_proxy.clone(),
|
||||
cwd: parent_turn_context.cwd.clone(),
|
||||
final_output_json_schema: None,
|
||||
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
|
||||
@@ -2150,6 +2277,16 @@ pub(crate) async fn run_task(
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let auto_compact_limit = turn_context
|
||||
.client
|
||||
.get_model_family()
|
||||
.auto_compact_token_limit()
|
||||
.unwrap_or(i64::MAX);
|
||||
let total_usage_tokens = sess.get_total_token_usage().await;
|
||||
if total_usage_tokens >= auto_compact_limit {
|
||||
run_auto_compact(&sess, &turn_context).await;
|
||||
}
|
||||
let event = EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: turn_context.client.get_model_context_window(),
|
||||
});
|
||||
@@ -2232,25 +2369,12 @@ pub(crate) async fn run_task(
|
||||
needs_follow_up,
|
||||
last_agent_message: turn_last_agent_message,
|
||||
} = turn_output;
|
||||
let limit = turn_context
|
||||
.client
|
||||
.get_model_family()
|
||||
.auto_compact_token_limit()
|
||||
.unwrap_or(i64::MAX);
|
||||
let total_usage_tokens = sess.get_total_token_usage().await;
|
||||
let token_limit_reached = total_usage_tokens >= limit;
|
||||
let token_limit_reached = total_usage_tokens >= auto_compact_limit;
|
||||
|
||||
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
|
||||
if token_limit_reached {
|
||||
if should_use_remote_compact_task(
|
||||
sess.as_ref(),
|
||||
&turn_context.client.get_provider(),
|
||||
) {
|
||||
run_inline_remote_auto_compact_task(sess.clone(), turn_context.clone())
|
||||
.await;
|
||||
} else {
|
||||
run_inline_auto_compact_task(sess.clone(), turn_context.clone()).await;
|
||||
}
|
||||
if token_limit_reached && needs_follow_up {
|
||||
run_auto_compact(&sess, &turn_context).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2292,7 +2416,15 @@ pub(crate) async fn run_task(
|
||||
last_agent_message
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
|
||||
if should_use_remote_compact_task(sess.as_ref(), &turn_context.client.get_provider()) {
|
||||
run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await;
|
||||
} else {
|
||||
run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "trace",
|
||||
skip_all,
|
||||
fields(
|
||||
turn_id = %turn_context.sub_id,
|
||||
@@ -2432,7 +2564,7 @@ async fn drain_in_flight(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(
|
||||
#[instrument(level = "trace",
|
||||
skip_all,
|
||||
fields(
|
||||
turn_id = %turn_context.sub_id,
|
||||
@@ -2461,7 +2593,7 @@ async fn try_run_turn(
|
||||
.client
|
||||
.clone()
|
||||
.stream(prompt)
|
||||
.instrument(info_span!("stream_request"))
|
||||
.instrument(trace_span!("stream_request"))
|
||||
.or_cancel(&cancellation_token)
|
||||
.await??;
|
||||
|
||||
@@ -2477,9 +2609,9 @@ async fn try_run_turn(
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
let mut active_item: Option<TurnItem> = None;
|
||||
let mut should_emit_turn_diff = false;
|
||||
let receiving_span = info_span!("receiving_stream");
|
||||
let receiving_span = trace_span!("receiving_stream");
|
||||
let outcome: CodexResult<TurnRunResult> = loop {
|
||||
let handle_responses = info_span!(
|
||||
let handle_responses = trace_span!(
|
||||
parent: &receiving_span,
|
||||
"handle_responses",
|
||||
otel.name = field::Empty,
|
||||
@@ -2489,7 +2621,7 @@ async fn try_run_turn(
|
||||
|
||||
let event = match stream
|
||||
.next()
|
||||
.instrument(info_span!(parent: &handle_responses, "receiving"))
|
||||
.instrument(trace_span!(parent: &handle_responses, "receiving"))
|
||||
.or_cancel(&cancellation_token)
|
||||
.await
|
||||
{
|
||||
@@ -2774,7 +2906,7 @@ mod tests {
|
||||
user_instructions: config.user_instructions.clone(),
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -2846,7 +2978,7 @@ mod tests {
|
||||
user_instructions: config.user_instructions.clone(),
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -3050,7 +3182,7 @@ mod tests {
|
||||
user_instructions: config.user_instructions.clone(),
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
@@ -3141,7 +3273,7 @@ mod tests {
|
||||
user_instructions: config.user_instructions.clone(),
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
|
||||
@@ -282,6 +282,7 @@ async fn handle_exec_approval(
|
||||
event.cwd,
|
||||
event.reason,
|
||||
event.proposed_execpolicy_amendment,
|
||||
event.network_preflight_only,
|
||||
);
|
||||
let decision = await_approval_with_cancel(
|
||||
approval_fut,
|
||||
|
||||
195
codex-rs/core/src/config/constraint.rs
Normal file
195
codex-rs/core/src/config/constraint.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
#[error("{message}")]
|
||||
pub struct ConstraintError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ConstraintError {
|
||||
pub fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: format!(
|
||||
"value `{}` is not in the allowed set {}",
|
||||
candidate.into(),
|
||||
allowed.into()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ConstraintResult<T> = Result<T, ConstraintError>;
|
||||
|
||||
impl From<ConstraintError> for std::io::Error {
|
||||
fn from(err: ConstraintError) -> Self {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, err)
|
||||
}
|
||||
}
|
||||
|
||||
type ConstraintValidator<T> = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Constrained<T> {
|
||||
value: T,
|
||||
validator: Arc<ConstraintValidator<T>>,
|
||||
}
|
||||
|
||||
impl<T: Send + Sync> Constrained<T> {
|
||||
pub fn new(
|
||||
initial_value: T,
|
||||
validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static,
|
||||
) -> ConstraintResult<Self> {
|
||||
let validator: Arc<ConstraintValidator<T>> = Arc::new(validator);
|
||||
validator(&initial_value)?;
|
||||
Ok(Self {
|
||||
value: initial_value,
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn allow_any(initial_value: T) -> Self {
|
||||
Self {
|
||||
value: initial_value,
|
||||
validator: Arc::new(|_| Ok(())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allow_values(initial_value: T, allowed: Vec<T>) -> ConstraintResult<Self>
|
||||
where
|
||||
T: PartialEq + Send + Sync + fmt::Debug + 'static,
|
||||
{
|
||||
Self::new(initial_value, move |candidate| {
|
||||
if allowed.contains(candidate) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
format!("{candidate:?}"),
|
||||
format!("{allowed:?}"),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &T {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn value(&self) -> T
|
||||
where
|
||||
T: Copy,
|
||||
{
|
||||
self.value
|
||||
}
|
||||
|
||||
pub fn can_set(&self, candidate: &T) -> ConstraintResult<()> {
|
||||
(self.validator)(candidate)
|
||||
}
|
||||
|
||||
pub fn set(&mut self, value: T) -> ConstraintResult<()> {
|
||||
(self.validator)(&value)?;
|
||||
self.value = value;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Constrained<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: fmt::Debug> fmt::Debug for Constrained<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Constrained")
|
||||
.field("value", &self.value)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq> PartialEq for Constrained<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.value == other.value
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn constrained_allow_any_accepts_any_value() {
|
||||
let mut constrained = Constrained::allow_any(5);
|
||||
constrained.set(-10).expect("allow any accepts all values");
|
||||
assert_eq!(constrained.value(), -10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_new_rejects_invalid_initial_value() {
|
||||
let result = Constrained::new(0, |value| {
|
||||
if *value > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
value.to_string(),
|
||||
"positive values",
|
||||
))
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(ConstraintError::invalid_value("0", "positive values"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_set_rejects_invalid_value_and_leaves_previous() {
|
||||
let mut constrained = Constrained::new(1, |value| {
|
||||
if *value > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
value.to_string(),
|
||||
"positive values",
|
||||
))
|
||||
}
|
||||
})
|
||||
.expect("initial value should be accepted");
|
||||
|
||||
let err = constrained
|
||||
.set(-5)
|
||||
.expect_err("negative values should be rejected");
|
||||
assert_eq!(err, ConstraintError::invalid_value("-5", "positive values"));
|
||||
assert_eq!(constrained.value(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_can_set_allows_probe_without_setting() {
|
||||
let constrained = Constrained::new(1, |value| {
|
||||
if *value > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
value.to_string(),
|
||||
"positive values",
|
||||
))
|
||||
}
|
||||
})
|
||||
.expect("initial value should be accepted");
|
||||
|
||||
constrained
|
||||
.can_set(&2)
|
||||
.expect("can_set should accept positive value");
|
||||
let err = constrained
|
||||
.can_set(&-1)
|
||||
.expect_err("can_set should reject negative value");
|
||||
assert_eq!(err, ConstraintError::invalid_value("-1", "positive values"));
|
||||
assert_eq!(constrained.value(), 1);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ use crate::auth::AuthCredentialsStoreMode;
|
||||
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
|
||||
use crate::config::types::History;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::NetworkProxyConfig;
|
||||
use crate::config::types::NetworkProxyConfigToml;
|
||||
use crate::config::types::NetworkProxyMode;
|
||||
use crate::config::types::Notice;
|
||||
use crate::config::types::Notifications;
|
||||
use crate::config::types::OtelConfig;
|
||||
@@ -26,7 +29,6 @@ use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::util::resolve_path;
|
||||
use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
@@ -54,10 +56,14 @@ use crate::config::profile::ConfigProfile;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
mod constraint;
|
||||
pub mod edit;
|
||||
pub mod profile;
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
pub use constraint::Constrained;
|
||||
pub use constraint::ConstraintError;
|
||||
pub use constraint::ConstraintResult;
|
||||
|
||||
pub use service::ConfigService;
|
||||
pub use service::ConfigServiceError;
|
||||
@@ -84,6 +90,11 @@ pub(crate) fn test_config() -> Config {
|
||||
.expect("load default test config")
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> std::io::Result<PathBuf> {
|
||||
let codex_home = find_codex_home()?;
|
||||
Ok(codex_home.join(CONFIG_TOML_FILE))
|
||||
}
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Config {
|
||||
@@ -106,7 +117,7 @@ pub struct Config {
|
||||
pub model_provider: ModelProviderInfo,
|
||||
|
||||
/// Approval policy for executing commands.
|
||||
pub approval_policy: AskForApproval,
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
|
||||
@@ -119,6 +130,8 @@ pub struct Config {
|
||||
pub forced_auto_mode_downgraded_on_windows: bool,
|
||||
|
||||
pub shell_environment_policy: ShellEnvironmentPolicy,
|
||||
/// Network proxy settings for routing sandboxed network access.
|
||||
pub network_proxy: NetworkProxyConfig,
|
||||
|
||||
/// When `true`, `AgentReasoning` events emitted by the backend will be
|
||||
/// suppressed from the frontend output. This can reduce visual noise when
|
||||
@@ -560,6 +573,9 @@ pub struct ConfigToml {
|
||||
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
|
||||
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
|
||||
|
||||
/// Network proxy settings for sandboxed network access.
|
||||
pub network_proxy: Option<NetworkProxyConfigToml>,
|
||||
|
||||
/// Optional external command to spawn for end-user notifications.
|
||||
#[serde(default)]
|
||||
pub notify: Option<Vec<String>>,
|
||||
@@ -688,8 +704,8 @@ pub struct ConfigToml {
|
||||
pub notice: Option<Notice>,
|
||||
|
||||
/// Legacy, now use features
|
||||
pub experimental_instructions_file: Option<PathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<PathBuf>,
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_rmcp_client: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
@@ -722,6 +738,143 @@ impl From<ConfigToml> for UserSavedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_network_proxy_config() -> NetworkProxyConfig {
|
||||
NetworkProxyConfig {
|
||||
enabled: false,
|
||||
proxy_url: "http://127.0.0.1:3128".to_string(),
|
||||
admin_url: "http://127.0.0.1:8080".to_string(),
|
||||
mode: NetworkProxyMode::Full,
|
||||
no_proxy: default_no_proxy_entries()
|
||||
.iter()
|
||||
.map(|entry| (*entry).to_string())
|
||||
.collect(),
|
||||
poll_interval_ms: 1000,
|
||||
mitm_ca_cert_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_network_proxy_config(cfg: &ConfigToml, codex_home: &Path) -> NetworkProxyConfig {
|
||||
let mut resolved = default_network_proxy_config();
|
||||
let Some(network_proxy) = cfg.network_proxy.clone() else {
|
||||
return resolved;
|
||||
};
|
||||
let mitm_ca_cert_path = resolve_network_proxy_mitm_ca_path(&network_proxy, codex_home);
|
||||
|
||||
if let Some(enabled) = network_proxy.enabled {
|
||||
resolved.enabled = enabled;
|
||||
}
|
||||
if let Some(proxy_url) = network_proxy.proxy_url {
|
||||
let trimmed = proxy_url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
resolved.proxy_url = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(admin_url) = network_proxy.admin_url {
|
||||
let trimmed = admin_url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
resolved.admin_url = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(mode) = network_proxy.mode {
|
||||
resolved.mode = mode;
|
||||
}
|
||||
if let Some(no_proxy) = network_proxy.no_proxy {
|
||||
resolved.no_proxy = normalize_no_proxy_entries(no_proxy);
|
||||
}
|
||||
ensure_default_no_proxy_entries(&mut resolved.no_proxy);
|
||||
if let Some(poll_interval_ms) = network_proxy.poll_interval_ms
|
||||
&& poll_interval_ms > 0
|
||||
{
|
||||
resolved.poll_interval_ms = poll_interval_ms;
|
||||
}
|
||||
resolved.mitm_ca_cert_path = mitm_ca_cert_path;
|
||||
resolved
|
||||
}
|
||||
|
||||
fn resolve_network_proxy_mitm_ca_path(
|
||||
network_proxy: &NetworkProxyConfigToml,
|
||||
codex_home: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
let mitm = network_proxy.mitm.as_ref()?;
|
||||
if !mitm.enabled.unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let ca_cert_path = mitm
|
||||
.ca_cert_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from("network_proxy/mitm/ca.pem"));
|
||||
if ca_cert_path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(resolve_network_proxy_path(&ca_cert_path, codex_home))
|
||||
}
|
||||
|
||||
fn resolve_network_proxy_path(path: &Path, codex_home: &Path) -> PathBuf {
|
||||
let expanded = expand_tilde_path(path);
|
||||
if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
codex_home.join(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde_path(path: &Path) -> PathBuf {
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str == "~" {
|
||||
return home_dir().unwrap_or_else(|| path.to_path_buf());
|
||||
}
|
||||
if let Some(rest) = path_str.strip_prefix("~/")
|
||||
&& let Some(home) = home_dir()
|
||||
{
|
||||
return home.join(rest);
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
fn normalize_no_proxy_entries(entries: Vec<String>) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for entry in entries {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if out
|
||||
.iter()
|
||||
.any(|existing| existing.eq_ignore_ascii_case(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ensure_default_no_proxy_entries(entries: &mut Vec<String>) {
|
||||
for entry in default_no_proxy_entries() {
|
||||
if entries
|
||||
.iter()
|
||||
.any(|existing| existing.eq_ignore_ascii_case(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entries.push(entry.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn default_no_proxy_entries() -> [&'static str; 9] {
|
||||
[
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"*.local",
|
||||
".local",
|
||||
"169.254.0.0/16",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectConfig {
|
||||
pub trust_level: Option<TrustLevel>,
|
||||
@@ -762,9 +915,11 @@ pub struct GhostSnapshotToml {
|
||||
#[serde(alias = "ignore_untracked_files_over_bytes")]
|
||||
pub ignore_large_untracked_files: Option<i64>,
|
||||
/// Ignore untracked directories that contain this many files or more.
|
||||
/// (Still emits a warning.)
|
||||
/// (Still emits a warning unless warnings are disabled.)
|
||||
#[serde(alias = "large_untracked_dir_warning_threshold")]
|
||||
pub ignore_large_untracked_dirs: Option<i64>,
|
||||
/// Disable all ghost snapshot warning events.
|
||||
pub disable_warnings: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -1026,15 +1181,14 @@ impl Config {
|
||||
.or(cfg.approval_policy)
|
||||
.unwrap_or_else(|| {
|
||||
if active_project.is_trusted() {
|
||||
// If no explicit approval policy is set, but we trust cwd, default to OnRequest
|
||||
AskForApproval::OnRequest
|
||||
} else if active_project.is_untrusted() {
|
||||
// If project is explicitly marked untrusted, require approval for non-safe commands
|
||||
AskForApproval::UnlessTrusted
|
||||
} else {
|
||||
AskForApproval::default()
|
||||
}
|
||||
});
|
||||
let approval_policy = Constrained::allow_any(approval_policy);
|
||||
let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override
|
||||
.is_some()
|
||||
|| config_profile.approval_policy.is_some()
|
||||
@@ -1043,6 +1197,11 @@ impl Config {
|
||||
|| config_profile.sandbox_mode.is_some()
|
||||
|| cfg.sandbox_mode.is_some();
|
||||
|
||||
let mut network_proxy = resolve_network_proxy_config(&cfg, &codex_home);
|
||||
if !features.enabled(Feature::NetworkProxy) {
|
||||
network_proxy.enabled = false;
|
||||
}
|
||||
|
||||
let mut model_providers = built_in_model_providers();
|
||||
// Merge user-defined providers into the built-in list.
|
||||
for (key, provider) in cfg.model_providers.into_iter() {
|
||||
@@ -1084,6 +1243,11 @@ impl Config {
|
||||
config.ignore_large_untracked_dirs =
|
||||
if threshold > 0 { Some(threshold) } else { None };
|
||||
}
|
||||
if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref()
|
||||
&& let Some(disable_warnings) = ghost_snapshot.disable_warnings
|
||||
{
|
||||
config.disable_warnings = disable_warnings;
|
||||
}
|
||||
config
|
||||
};
|
||||
|
||||
@@ -1122,9 +1286,8 @@ impl Config {
|
||||
.experimental_instructions_file
|
||||
.as_ref()
|
||||
.or(cfg.experimental_instructions_file.as_ref());
|
||||
let file_base_instructions = Self::load_override_from_file(
|
||||
let file_base_instructions = Self::try_read_non_empty_file(
|
||||
experimental_instructions_path,
|
||||
&resolved_cwd,
|
||||
"experimental instructions file",
|
||||
)?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
@@ -1134,9 +1297,8 @@ impl Config {
|
||||
.experimental_compact_prompt_file
|
||||
.as_ref()
|
||||
.or(cfg.experimental_compact_prompt_file.as_ref());
|
||||
let file_compact_prompt = Self::load_override_from_file(
|
||||
let file_compact_prompt = Self::try_read_non_empty_file(
|
||||
experimental_compact_prompt_path,
|
||||
&resolved_cwd,
|
||||
"experimental compact prompt file",
|
||||
)?;
|
||||
let compact_prompt = compact_prompt.or(file_compact_prompt);
|
||||
@@ -1161,6 +1323,7 @@ impl Config {
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode,
|
||||
forced_auto_mode_downgraded_on_windows,
|
||||
shell_environment_policy,
|
||||
network_proxy,
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
@@ -1268,21 +1431,21 @@ impl Config {
|
||||
None
|
||||
}
|
||||
|
||||
fn load_override_from_file(
|
||||
path: Option<&PathBuf>,
|
||||
cwd: &Path,
|
||||
description: &str,
|
||||
/// If `path` is `Some`, attempts to read the file at the given path and
|
||||
/// returns its contents as a trimmed `String`. If the file is empty, or
|
||||
/// is `Some` but cannot be read, returns an `Err`.
|
||||
fn try_read_non_empty_file(
|
||||
path: Option<&AbsolutePathBuf>,
|
||||
context: &str,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
let Some(p) = path else {
|
||||
let Some(path) = path else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let full_path = resolve_path(cwd, p);
|
||||
|
||||
let contents = std::fs::read_to_string(&full_path).map_err(|e| {
|
||||
let contents = std::fs::read_to_string(path).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("failed to read {description} {}: {e}", full_path.display()),
|
||||
format!("failed to read {context} {}: {e}", path.display()),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1290,7 +1453,7 @@ impl Config {
|
||||
if s.is_empty() {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("{description} is empty: {}", full_path.display()),
|
||||
format!("{context} is empty: {}", path.display()),
|
||||
))
|
||||
} else {
|
||||
Ok(Some(s))
|
||||
@@ -1396,6 +1559,51 @@ persistence = "none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_proxy_is_rollout_gated_by_feature_flag() -> std::io::Result<()> {
|
||||
let cfg_without_feature_flag = r#"
|
||||
[network_proxy]
|
||||
enabled = true
|
||||
"#;
|
||||
let parsed_without_feature_flag =
|
||||
toml::from_str::<ConfigToml>(cfg_without_feature_flag).expect("TOML parse should work");
|
||||
let cwd_temp_dir = TempDir::new().unwrap();
|
||||
std::fs::write(cwd_temp_dir.path().join(".git"), "gitdir: nowhere")?;
|
||||
let codex_home_temp_dir = TempDir::new().unwrap();
|
||||
let config_without_feature_flag = Config::load_from_base_config_with_overrides(
|
||||
parsed_without_feature_flag,
|
||||
ConfigOverrides {
|
||||
cwd: Some(cwd_temp_dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
codex_home_temp_dir.path().to_path_buf(),
|
||||
)?;
|
||||
assert!(!config_without_feature_flag.network_proxy.enabled);
|
||||
|
||||
let cfg_with_feature_flag = r#"
|
||||
[features]
|
||||
network_proxy = true
|
||||
|
||||
[network_proxy]
|
||||
enabled = true
|
||||
"#;
|
||||
let parsed_with_feature_flag =
|
||||
toml::from_str::<ConfigToml>(cfg_with_feature_flag).expect("TOML parse should work");
|
||||
let cwd_temp_dir = TempDir::new().unwrap();
|
||||
std::fs::write(cwd_temp_dir.path().join(".git"), "gitdir: nowhere")?;
|
||||
let codex_home_temp_dir = TempDir::new().unwrap();
|
||||
let config_with_feature_flag = Config::load_from_base_config_with_overrides(
|
||||
parsed_with_feature_flag,
|
||||
ConfigOverrides {
|
||||
cwd: Some(cwd_temp_dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
codex_home_temp_dir.path().to_path_buf(),
|
||||
)?;
|
||||
assert!(config_with_feature_flag.network_proxy.enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
let cfg = r#"
|
||||
@@ -2794,7 +3002,9 @@ model = "gpt-5.1-codex"
|
||||
std::fs::write(&prompt_path, " summarize differently ")?;
|
||||
|
||||
let cfg = ConfigToml {
|
||||
experimental_compact_prompt_file: Some(PathBuf::from("compact_prompt.txt")),
|
||||
experimental_compact_prompt_file: Some(AbsolutePathBuf::from_absolute_path(
|
||||
prompt_path,
|
||||
)?),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -2945,11 +3155,12 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
network_proxy: default_network_proxy_config(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -3020,11 +3231,12 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai-chat-completions".to_string(),
|
||||
model_provider: fixture.openai_chat_completions_provider.clone(),
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
network_proxy: default_network_proxy_config(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -3110,11 +3322,12 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
network_proxy: default_network_proxy_config(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -3186,11 +3399,12 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
network_proxy: default_network_proxy_config(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -3500,26 +3714,21 @@ trust_level = "untrusted"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> std::io::Result<()> {
|
||||
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let test_project_dir = TempDir::new()?;
|
||||
let test_path = test_project_dir.path();
|
||||
|
||||
let mut projects = std::collections::HashMap::new();
|
||||
projects.insert(
|
||||
test_path.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Untrusted),
|
||||
},
|
||||
);
|
||||
|
||||
let cfg = ConfigToml {
|
||||
projects: Some(projects),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigToml {
|
||||
projects: Some(HashMap::from([(
|
||||
test_path.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Untrusted),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigOverrides {
|
||||
cwd: Some(test_path.to_path_buf()),
|
||||
..Default::default()
|
||||
@@ -3529,7 +3738,7 @@ trust_level = "untrusted"
|
||||
|
||||
// Verify that untrusted projects get UnlessTrusted approval policy
|
||||
assert_eq!(
|
||||
config.approval_policy,
|
||||
config.approval_policy.value(),
|
||||
AskForApproval::UnlessTrusted,
|
||||
"Expected UnlessTrusted approval policy for untrusted project"
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::protocol::AskForApproval;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -21,8 +21,8 @@ pub struct ConfigProfile {
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
pub experimental_instructions_file: Option<PathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<PathBuf>,
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_rmcp_client: Option<bool>,
|
||||
|
||||
@@ -7,10 +7,11 @@ use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::path_utils;
|
||||
use codex_app_server_protocol::Config as ApiConfig;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigLayerMetadata;
|
||||
use codex_app_server_protocol::ConfigLayerName;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigReadResponse;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
@@ -470,9 +471,10 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> {
|
||||
}
|
||||
|
||||
fn paths_match(expected: &Path, provided: &Path) -> bool {
|
||||
if let (Ok(expanded_expected), Ok(expanded_provided)) =
|
||||
(expected.canonicalize(), provided.canonicalize())
|
||||
{
|
||||
if let (Ok(expanded_expected), Ok(expanded_provided)) = (
|
||||
path_utils::normalize_for_path_comparison(expected),
|
||||
path_utils::normalize_for_path_comparison(provided),
|
||||
) {
|
||||
return expanded_expected == expanded_provided;
|
||||
}
|
||||
|
||||
@@ -497,12 +499,12 @@ fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a Tom
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn override_message(layer: &ConfigLayerName) -> String {
|
||||
fn override_message(layer: &ConfigLayerSource) -> String {
|
||||
match layer {
|
||||
ConfigLayerName::Mdm => "Overridden by managed policy (mdm)".to_string(),
|
||||
ConfigLayerName::System => "Overridden by managed config (system)".to_string(),
|
||||
ConfigLayerName::SessionFlags => "Overridden by session flags".to_string(),
|
||||
ConfigLayerName::User => "Overridden by user config".to_string(),
|
||||
ConfigLayerSource::Mdm { .. } => "Overridden by managed policy (mdm)".to_string(),
|
||||
ConfigLayerSource::System { .. } => "Overridden by managed config (system)".to_string(),
|
||||
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
|
||||
ConfigLayerSource::User { .. } => "Overridden by user config".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +578,7 @@ mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -677,16 +680,19 @@ remote_compaction = true
|
||||
#[tokio::test]
|
||||
async fn read_includes_origins_and_layers() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap();
|
||||
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&user_path, "model = \"user\"").unwrap();
|
||||
let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file");
|
||||
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
|
||||
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
||||
|
||||
let service = ConfigService::with_overrides(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
},
|
||||
@@ -707,12 +713,20 @@ remote_compaction = true
|
||||
.get("approval_policy")
|
||||
.expect("origin")
|
||||
.name,
|
||||
ConfigLayerName::System
|
||||
ConfigLayerSource::System {
|
||||
file: managed_file.clone(),
|
||||
}
|
||||
);
|
||||
let layers = response.layers.expect("layers present");
|
||||
assert_eq!(layers.first().unwrap().name, ConfigLayerName::System);
|
||||
assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags);
|
||||
assert_eq!(layers.last().unwrap().name, ConfigLayerName::User);
|
||||
assert_eq!(
|
||||
layers.first().unwrap().name,
|
||||
ConfigLayerSource::System { file: managed_file }
|
||||
);
|
||||
assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags);
|
||||
assert_eq!(
|
||||
layers.last().unwrap().name,
|
||||
ConfigLayerSource::User { file: user_file }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -726,12 +740,13 @@ remote_compaction = true
|
||||
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
|
||||
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
||||
|
||||
let service = ConfigService::with_overrides(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
},
|
||||
@@ -764,7 +779,7 @@ remote_compaction = true
|
||||
.get("approval_policy")
|
||||
.expect("origin")
|
||||
.name,
|
||||
ConfigLayerName::System
|
||||
ConfigLayerSource::System { file: managed_file }
|
||||
);
|
||||
assert_eq!(result.status, WriteStatus::Ok);
|
||||
assert!(result.overridden_metadata.is_none());
|
||||
@@ -773,7 +788,8 @@ remote_compaction = true
|
||||
#[tokio::test]
|
||||
async fn version_conflict_rejected() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap();
|
||||
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&user_path, "model = \"user\"").unwrap();
|
||||
|
||||
let service = ConfigService::new(tmp.path().to_path_buf(), vec![]);
|
||||
let error = service
|
||||
@@ -830,7 +846,7 @@ remote_compaction = true
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
},
|
||||
@@ -860,10 +876,13 @@ remote_compaction = true
|
||||
#[tokio::test]
|
||||
async fn read_reports_managed_overrides_user_and_session_flags() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap();
|
||||
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&user_path, "model = \"user\"").unwrap();
|
||||
let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file");
|
||||
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
std::fs::write(&managed_path, "model = \"system\"").unwrap();
|
||||
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
||||
|
||||
let cli_overrides = vec![(
|
||||
"model".to_string(),
|
||||
@@ -874,7 +893,7 @@ remote_compaction = true
|
||||
tmp.path().to_path_buf(),
|
||||
cli_overrides,
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
},
|
||||
@@ -890,12 +909,20 @@ remote_compaction = true
|
||||
assert_eq!(response.config.model.as_deref(), Some("system"));
|
||||
assert_eq!(
|
||||
response.origins.get("model").expect("origin").name,
|
||||
ConfigLayerName::System
|
||||
ConfigLayerSource::System {
|
||||
file: managed_file.clone(),
|
||||
}
|
||||
);
|
||||
let layers = response.layers.expect("layers");
|
||||
assert_eq!(layers.first().unwrap().name, ConfigLayerName::System);
|
||||
assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags);
|
||||
assert_eq!(layers.get(2).unwrap().name, ConfigLayerName::User);
|
||||
assert_eq!(
|
||||
layers.first().unwrap().name,
|
||||
ConfigLayerSource::System { file: managed_file }
|
||||
);
|
||||
assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags);
|
||||
assert_eq!(
|
||||
layers.get(2).unwrap().name,
|
||||
ConfigLayerSource::User { file: user_file }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -905,12 +932,13 @@ remote_compaction = true
|
||||
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
|
||||
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
||||
|
||||
let service = ConfigService::with_overrides(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
},
|
||||
@@ -929,7 +957,10 @@ remote_compaction = true
|
||||
|
||||
assert_eq!(result.status, WriteStatus::OkOverridden);
|
||||
let overridden = result.overridden_metadata.expect("overridden metadata");
|
||||
assert_eq!(overridden.overriding_layer.name, ConfigLayerName::System);
|
||||
assert_eq!(
|
||||
overridden.overriding_layer.name,
|
||||
ConfigLayerSource::System { file: managed_file }
|
||||
);
|
||||
assert_eq!(overridden.effective_value, serde_json::json!("never"));
|
||||
}
|
||||
|
||||
|
||||
@@ -435,6 +435,47 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum NetworkProxyMode {
|
||||
Limited,
|
||||
#[default]
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct NetworkProxyConfigToml {
|
||||
pub enabled: Option<bool>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub admin_url: Option<String>,
|
||||
pub mode: Option<NetworkProxyMode>,
|
||||
pub no_proxy: Option<Vec<String>>,
|
||||
pub poll_interval_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub mitm: Option<NetworkProxyMitmConfigToml>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct NetworkProxyMitmConfigToml {
|
||||
pub enabled: Option<bool>,
|
||||
pub inspect: Option<bool>,
|
||||
pub max_body_bytes: Option<i64>,
|
||||
pub ca_cert_path: Option<PathBuf>,
|
||||
pub ca_key_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NetworkProxyConfig {
|
||||
pub enabled: bool,
|
||||
pub proxy_url: String,
|
||||
pub admin_url: String,
|
||||
pub mode: NetworkProxyMode,
|
||||
pub no_proxy: Vec<String>,
|
||||
pub poll_interval_ms: i64,
|
||||
pub mitm_ca_cert_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ShellEnvironmentPolicyInherit {
|
||||
|
||||
@@ -16,7 +16,7 @@ Exported from `codex_core::config_loader`:
|
||||
- `origins() -> HashMap<String, ConfigLayerMetadata>`
|
||||
- `layers_high_to_low() -> Vec<ConfigLayer>`
|
||||
- `with_user_config(user_config) -> ConfigLayerStack`
|
||||
- `ConfigLayerEntry` (one layer’s `{name, source, config, version}`)
|
||||
- `ConfigLayerEntry` (one layer’s `{name, config, version}`; `name` carries source metadata)
|
||||
- `LoaderOverrides` (test/override hooks for managed config sources)
|
||||
- `merge_toml_values(base, overlay)` (public helper used elsewhere)
|
||||
|
||||
@@ -61,4 +61,3 @@ Implementation is split by concern:
|
||||
- `merge.rs`: recursive TOML merge.
|
||||
- `fingerprint.rs`: stable per-layer hashing and per-key origins traversal.
|
||||
- `macos.rs`: managed preferences integration (macOS only).
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ mod state;
|
||||
mod tests;
|
||||
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use codex_app_server_protocol::ConfigLayerName;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
pub use merge::merge_toml_values;
|
||||
@@ -20,8 +20,8 @@ pub use state::ConfigLayerEntry;
|
||||
pub use state::ConfigLayerStack;
|
||||
pub use state::LoaderOverrides;
|
||||
|
||||
const SESSION_FLAGS_SOURCE: &str = "--config";
|
||||
const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64";
|
||||
const MDM_PREFERENCES_DOMAIN: &str = "com.openai.codex";
|
||||
const MDM_PREFERENCES_KEY: &str = "config_toml_base64";
|
||||
|
||||
/// Configuration layering pipeline (top overrides bottom):
|
||||
///
|
||||
@@ -51,24 +51,32 @@ pub async fn load_config_layers_state(
|
||||
.unwrap_or_else(|| layer_io::managed_config_default_path(codex_home));
|
||||
|
||||
let layers = layer_io::load_config_layers_internal(codex_home, overrides).await?;
|
||||
let cli_overrides = overrides::build_cli_overrides_layer(cli_overrides);
|
||||
let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides);
|
||||
let user_file = AbsolutePathBuf::from_absolute_path(codex_home.join(CONFIG_TOML_FILE))?;
|
||||
|
||||
let system = match layers.managed_config {
|
||||
Some(cfg) => {
|
||||
let system_file = AbsolutePathBuf::from_absolute_path(managed_config_path.clone())?;
|
||||
Some(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System { file: system_file },
|
||||
cfg,
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(ConfigLayerStack {
|
||||
user: ConfigLayerEntry::new(
|
||||
ConfigLayerName::User,
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
layers.base,
|
||||
),
|
||||
session_flags: ConfigLayerEntry::new(
|
||||
ConfigLayerName::SessionFlags,
|
||||
PathBuf::from(SESSION_FLAGS_SOURCE),
|
||||
cli_overrides,
|
||||
),
|
||||
system: layers.managed_config.map(|cfg| {
|
||||
ConfigLayerEntry::new(ConfigLayerName::System, managed_config_path.clone(), cfg)
|
||||
user: ConfigLayerEntry::new(ConfigLayerSource::User { file: user_file }, layers.base),
|
||||
session_flags: ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, cli_overrides_layer),
|
||||
system,
|
||||
mdm: layers.managed_preferences.map(|cfg| {
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Mdm {
|
||||
domain: MDM_PREFERENCES_DOMAIN.to_string(),
|
||||
key: MDM_PREFERENCES_KEY.to_string(),
|
||||
},
|
||||
cfg,
|
||||
)
|
||||
}),
|
||||
mdm: layers
|
||||
.managed_preferences
|
||||
.map(|cfg| ConfigLayerEntry::new(ConfigLayerName::Mdm, PathBuf::from(MDM_SOURCE), cfg)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::fingerprint::version_for_toml;
|
||||
use super::merge::merge_toml_values;
|
||||
use codex_app_server_protocol::ConfigLayer;
|
||||
use codex_app_server_protocol::ConfigLayerMetadata;
|
||||
use codex_app_server_protocol::ConfigLayerName;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -18,18 +18,16 @@ pub struct LoaderOverrides {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigLayerEntry {
|
||||
pub name: ConfigLayerName,
|
||||
pub source: PathBuf,
|
||||
pub name: ConfigLayerSource,
|
||||
pub config: TomlValue,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl ConfigLayerEntry {
|
||||
pub fn new(name: ConfigLayerName, source: PathBuf, config: TomlValue) -> Self {
|
||||
pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
|
||||
let version = version_for_toml(&config);
|
||||
Self {
|
||||
name,
|
||||
source,
|
||||
config,
|
||||
version,
|
||||
}
|
||||
@@ -38,7 +36,6 @@ impl ConfigLayerEntry {
|
||||
pub fn metadata(&self) -> ConfigLayerMetadata {
|
||||
ConfigLayerMetadata {
|
||||
name: self.name.clone(),
|
||||
source: self.source.display().to_string(),
|
||||
version: self.version.clone(),
|
||||
}
|
||||
}
|
||||
@@ -46,7 +43,6 @@ impl ConfigLayerEntry {
|
||||
pub fn as_layer(&self) -> ConfigLayer {
|
||||
ConfigLayer {
|
||||
name: self.name.clone(),
|
||||
source: self.source.display().to_string(),
|
||||
version: self.version.clone(),
|
||||
config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null),
|
||||
}
|
||||
@@ -64,11 +60,7 @@ pub struct ConfigLayerStack {
|
||||
impl ConfigLayerStack {
|
||||
pub fn with_user_config(&self, user_config: TomlValue) -> Self {
|
||||
Self {
|
||||
user: ConfigLayerEntry::new(
|
||||
self.user.name.clone(),
|
||||
self.user.source.clone(),
|
||||
user_config,
|
||||
),
|
||||
user: ConfigLayerEntry::new(self.user.name.clone(), user_config),
|
||||
session_flags: self.session_flags.clone(),
|
||||
system: self.system.clone(),
|
||||
mdm: self.mdm.clone(),
|
||||
|
||||
@@ -163,7 +163,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_codex_user_agent() {
|
||||
let user_agent = get_codex_user_agent();
|
||||
assert!(user_agent.starts_with("codex_cli_rs/"));
|
||||
let originator = originator().value.as_str();
|
||||
let prefix = format!("{originator}/");
|
||||
assert!(user_agent.starts_with(&prefix));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -204,7 +206,7 @@ mod tests {
|
||||
let originator_header = headers
|
||||
.get("originator")
|
||||
.expect("originator header missing");
|
||||
assert_eq!(originator_header.to_str().unwrap(), "codex_cli_rs");
|
||||
assert_eq!(originator_header.to_str().unwrap(), originator().value);
|
||||
|
||||
// User-Agent matches the computed Codex UA for that originator
|
||||
let expected_ua = get_codex_user_agent();
|
||||
@@ -241,9 +243,10 @@ mod tests {
|
||||
fn test_macos() {
|
||||
use regex_lite::Regex;
|
||||
let user_agent = get_codex_user_agent();
|
||||
let re = Regex::new(
|
||||
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
|
||||
)
|
||||
let originator = regex_lite::escape(originator().value.as_str());
|
||||
let re = Regex::new(&format!(
|
||||
r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$"
|
||||
))
|
||||
.unwrap();
|
||||
assert!(re.is_match(&user_agent));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use crate::config::types::EnvironmentVariablePattern;
|
||||
use crate::config::types::NetworkProxyConfig;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::config::types::ShellEnvironmentPolicyInherit;
|
||||
use crate::network_proxy;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const DEFAULT_SOCKS_PROXY_PORT: u16 = 8081;
|
||||
|
||||
/// Construct an environment map based on the rules in the specified policy. The
|
||||
/// resulting map can be passed directly to `Command::envs()` after calling
|
||||
/// `env_clear()` to ensure no unintended variables are leaked to the spawned
|
||||
@@ -11,8 +16,16 @@ use std::collections::HashSet;
|
||||
///
|
||||
/// The derivation follows the algorithm documented in the struct-level comment
|
||||
/// for [`ShellEnvironmentPolicy`].
|
||||
pub fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap<String, String> {
|
||||
populate_env(std::env::vars(), policy)
|
||||
pub fn create_env(
|
||||
policy: &ShellEnvironmentPolicy,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
) -> HashMap<String, String> {
|
||||
let mut env_map = populate_env(std::env::vars(), policy);
|
||||
if should_apply_network_proxy(network_proxy, sandbox_policy) {
|
||||
apply_network_proxy_env(&mut env_map, network_proxy);
|
||||
}
|
||||
env_map
|
||||
}
|
||||
|
||||
fn populate_env<I>(vars: I, policy: &ShellEnvironmentPolicy) -> HashMap<String, String>
|
||||
@@ -68,11 +81,245 @@ where
|
||||
env_map
|
||||
}
|
||||
|
||||
fn should_apply_network_proxy(
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> bool {
|
||||
if !network_proxy.enabled {
|
||||
return false;
|
||||
}
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
|
||||
SandboxPolicy::DangerFullAccess => true,
|
||||
SandboxPolicy::ReadOnly => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ProxyEndpoint {
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProxyEndpoints {
|
||||
http: Option<ProxyEndpoint>,
|
||||
socks: Option<ProxyEndpoint>,
|
||||
}
|
||||
|
||||
fn proxy_env_entries(
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
endpoints: &ProxyEndpoints,
|
||||
) -> Vec<String> {
|
||||
let mut entries = Vec::new();
|
||||
let no_proxy = normalize_no_proxy_value(&network_proxy.no_proxy);
|
||||
if !no_proxy.is_empty() {
|
||||
entries.push(format!("NO_PROXY={no_proxy}"));
|
||||
entries.push(format!("no_proxy={no_proxy}"));
|
||||
}
|
||||
|
||||
let http_proxy_url = endpoints
|
||||
.http
|
||||
.as_ref()
|
||||
.map(|endpoint| format_proxy_url("http", endpoint));
|
||||
let socks_proxy_url = endpoints
|
||||
.socks
|
||||
.as_ref()
|
||||
.map(|endpoint| format_proxy_url("socks5h", endpoint));
|
||||
let socks_host_port = endpoints
|
||||
.socks
|
||||
.as_ref()
|
||||
.map(|endpoint| format_host_port(&endpoint.host, endpoint.port));
|
||||
|
||||
if let Some(http_proxy_url) = http_proxy_url.as_ref() {
|
||||
for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] {
|
||||
entries.push(format!("{key}={http_proxy_url}"));
|
||||
}
|
||||
for key in [
|
||||
"YARN_HTTP_PROXY",
|
||||
"YARN_HTTPS_PROXY",
|
||||
"npm_config_http_proxy",
|
||||
"npm_config_https_proxy",
|
||||
"npm_config_proxy",
|
||||
] {
|
||||
entries.push(format!("{key}={http_proxy_url}"));
|
||||
}
|
||||
entries.push("ELECTRON_GET_USE_PROXY=true".to_string());
|
||||
}
|
||||
|
||||
if let Some(socks_proxy_url) = socks_proxy_url.as_ref() {
|
||||
entries.push(format!("ALL_PROXY={socks_proxy_url}"));
|
||||
entries.push(format!("all_proxy={socks_proxy_url}"));
|
||||
}
|
||||
|
||||
if let Some(socks_host_port) = socks_host_port.as_ref() {
|
||||
#[cfg(target_os = "macos")]
|
||||
entries.push(format!(
|
||||
"GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x {socks_host_port} %h %p'"
|
||||
));
|
||||
if let Some(socks_proxy_url) = socks_proxy_url.as_ref() {
|
||||
entries.push(format!("FTP_PROXY={socks_proxy_url}"));
|
||||
entries.push(format!("ftp_proxy={socks_proxy_url}"));
|
||||
}
|
||||
entries.push(format!("RSYNC_PROXY={socks_host_port}"));
|
||||
}
|
||||
|
||||
let docker_proxy = endpoints.http.as_ref().or(endpoints.socks.as_ref());
|
||||
if let Some(endpoint) = docker_proxy {
|
||||
let docker_proxy_url = format_proxy_url("http", endpoint);
|
||||
entries.push(format!("DOCKER_HTTP_PROXY={docker_proxy_url}"));
|
||||
entries.push(format!("DOCKER_HTTPS_PROXY={docker_proxy_url}"));
|
||||
}
|
||||
|
||||
if let Some(endpoint) = endpoints.http.as_ref() {
|
||||
entries.push("CLOUDSDK_PROXY_TYPE=https".to_string());
|
||||
entries.push("CLOUDSDK_PROXY_ADDRESS=localhost".to_string());
|
||||
let port = endpoint.port;
|
||||
entries.push(format!("CLOUDSDK_PROXY_PORT={port}"));
|
||||
}
|
||||
|
||||
if let Some(socks_proxy_url) = socks_proxy_url.as_ref() {
|
||||
entries.push(format!("GRPC_PROXY={socks_proxy_url}"));
|
||||
entries.push(format!("grpc_proxy={socks_proxy_url}"));
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn resolve_proxy_endpoints(network_proxy: &NetworkProxyConfig) -> ProxyEndpoints {
|
||||
let proxy_url = network_proxy.proxy_url.trim();
|
||||
if proxy_url.is_empty() {
|
||||
return ProxyEndpoints::default();
|
||||
}
|
||||
|
||||
let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else {
|
||||
return ProxyEndpoints::default();
|
||||
};
|
||||
let Some(port) = normalize_proxy_port(port) else {
|
||||
return ProxyEndpoints::default();
|
||||
};
|
||||
|
||||
let (host, is_loopback) = normalize_proxy_host(&host);
|
||||
let is_socks = proxy_url_scheme(proxy_url)
|
||||
.map(|scheme| scheme.to_ascii_lowercase().starts_with("socks"))
|
||||
.unwrap_or(false);
|
||||
let http = if is_socks {
|
||||
None
|
||||
} else {
|
||||
Some(ProxyEndpoint {
|
||||
host: host.clone(),
|
||||
port,
|
||||
})
|
||||
};
|
||||
let mut socks = if is_socks {
|
||||
Some(ProxyEndpoint { host, port })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if socks.is_none() && is_loopback {
|
||||
socks = Some(ProxyEndpoint {
|
||||
host: "localhost".to_string(),
|
||||
port: DEFAULT_SOCKS_PROXY_PORT,
|
||||
});
|
||||
}
|
||||
|
||||
ProxyEndpoints { http, socks }
|
||||
}
|
||||
|
||||
fn proxy_url_scheme(proxy_url: &str) -> Option<&str> {
|
||||
proxy_url.split_once("://").map(|(scheme, _)| scheme)
|
||||
}
|
||||
|
||||
fn normalize_proxy_host(host: &str) -> (String, bool) {
|
||||
let is_loopback =
|
||||
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1";
|
||||
if is_loopback {
|
||||
("localhost".to_string(), true)
|
||||
} else {
|
||||
(host.to_string(), false)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_proxy_port(port: i64) -> Option<u16> {
|
||||
if (1..=u16::MAX as i64).contains(&port) {
|
||||
Some(port as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn format_proxy_url(scheme: &str, endpoint: &ProxyEndpoint) -> String {
|
||||
let host = &endpoint.host;
|
||||
let port = endpoint.port;
|
||||
if endpoint.host.contains(':') {
|
||||
format!("{scheme}://[{host}]:{port}")
|
||||
} else {
|
||||
format!("{scheme}://{host}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_host_port(host: &str, port: u16) -> String {
|
||||
if host.contains(':') {
|
||||
format!("[{host}]:{port}")
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_network_proxy_env(
|
||||
env_map: &mut HashMap<String, String>,
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
) {
|
||||
let endpoints = resolve_proxy_endpoints(network_proxy);
|
||||
for entry in proxy_env_entries(network_proxy, &endpoints) {
|
||||
if let Some((key, value)) = entry.split_once('=') {
|
||||
env_map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(endpoint) = endpoints.http {
|
||||
let host = &endpoint.host;
|
||||
let port = endpoint.port;
|
||||
let gradle_opts = format!(
|
||||
"-Dhttp.proxyHost={host} -Dhttp.proxyPort={port} -Dhttps.proxyHost={host} -Dhttps.proxyPort={port}"
|
||||
);
|
||||
match env_map.get_mut("GRADLE_OPTS") {
|
||||
Some(existing) => {
|
||||
if !existing.contains("http.proxyHost") && !existing.contains("https.proxyHost") {
|
||||
if !existing.ends_with(' ') {
|
||||
existing.push(' ');
|
||||
}
|
||||
existing.push_str(&gradle_opts);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
env_map.insert("GRADLE_OPTS".to_string(), gradle_opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
network_proxy::apply_mitm_ca_env_if_enabled(env_map, network_proxy);
|
||||
}
|
||||
|
||||
fn normalize_no_proxy_value(entries: &[String]) -> String {
|
||||
let mut out = Vec::new();
|
||||
for entry in entries {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
out.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::NetworkProxyMode;
|
||||
use crate::config::types::ShellEnvironmentPolicyInherit;
|
||||
use maplit::hashmap;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
pairs
|
||||
@@ -191,4 +438,54 @@ mod tests {
|
||||
};
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_env_entries_are_deterministic() {
|
||||
let network_proxy = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
proxy_url: "http://localhost:3128".to_string(),
|
||||
admin_url: "http://localhost:8080".to_string(),
|
||||
mode: NetworkProxyMode::Full,
|
||||
no_proxy: vec!["localhost".to_string(), "127.0.0.1".to_string()],
|
||||
poll_interval_ms: 1000,
|
||||
mitm_ca_cert_path: None,
|
||||
};
|
||||
let endpoints = resolve_proxy_endpoints(&network_proxy);
|
||||
let entries = proxy_env_entries(&network_proxy, &endpoints);
|
||||
|
||||
let mut expected = vec![
|
||||
"NO_PROXY=localhost,127.0.0.1".to_string(),
|
||||
"no_proxy=localhost,127.0.0.1".to_string(),
|
||||
"HTTP_PROXY=http://localhost:3128".to_string(),
|
||||
"HTTPS_PROXY=http://localhost:3128".to_string(),
|
||||
"http_proxy=http://localhost:3128".to_string(),
|
||||
"https_proxy=http://localhost:3128".to_string(),
|
||||
"YARN_HTTP_PROXY=http://localhost:3128".to_string(),
|
||||
"YARN_HTTPS_PROXY=http://localhost:3128".to_string(),
|
||||
"npm_config_http_proxy=http://localhost:3128".to_string(),
|
||||
"npm_config_https_proxy=http://localhost:3128".to_string(),
|
||||
"npm_config_proxy=http://localhost:3128".to_string(),
|
||||
"ELECTRON_GET_USE_PROXY=true".to_string(),
|
||||
"ALL_PROXY=socks5h://localhost:8081".to_string(),
|
||||
"all_proxy=socks5h://localhost:8081".to_string(),
|
||||
];
|
||||
#[cfg(target_os = "macos")]
|
||||
expected.push(
|
||||
"GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:8081 %h %p'".to_string(),
|
||||
);
|
||||
expected.extend([
|
||||
"FTP_PROXY=socks5h://localhost:8081".to_string(),
|
||||
"ftp_proxy=socks5h://localhost:8081".to_string(),
|
||||
"RSYNC_PROXY=localhost:8081".to_string(),
|
||||
"DOCKER_HTTP_PROXY=http://localhost:3128".to_string(),
|
||||
"DOCKER_HTTPS_PROXY=http://localhost:3128".to_string(),
|
||||
"CLOUDSDK_PROXY_TYPE=https".to_string(),
|
||||
"CLOUDSDK_PROXY_ADDRESS=localhost".to_string(),
|
||||
"CLOUDSDK_PROXY_PORT=3128".to_string(),
|
||||
"GRPC_PROXY=socks5h://localhost:8081".to_string(),
|
||||
"grpc_proxy=socks5h://localhost:8081".to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(entries, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,33 @@ pub(crate) use legacy::LegacyFeatureToggles;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
Experimental,
|
||||
Beta,
|
||||
Beta {
|
||||
menu_description: &'static str,
|
||||
announcement: &'static str,
|
||||
},
|
||||
Stable,
|
||||
Deprecated,
|
||||
Removed,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
pub fn beta_menu_description(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Stage::Beta {
|
||||
menu_description, ..
|
||||
} => Some(menu_description),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn beta_announcement(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Stage::Beta { announcement, .. } => Some(announcement),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique features toggled via configuration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Feature {
|
||||
@@ -64,6 +85,8 @@ pub enum Feature {
|
||||
ShellSnapshot,
|
||||
/// Experimental TUI v2 (viewport) implementation.
|
||||
Tui2,
|
||||
/// Route subprocess network access through the Codex network proxy and surface approvals.
|
||||
NetworkProxy,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
@@ -292,13 +315,44 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
// Unstable features.
|
||||
FeatureSpec {
|
||||
id: Feature::UnifiedExec,
|
||||
key: "unified_exec",
|
||||
id: Feature::WebSearchRequest,
|
||||
key: "web_search_request",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: false,
|
||||
},
|
||||
// Beta program. Rendered in the `/experimental` menu for users.
|
||||
FeatureSpec {
|
||||
id: Feature::Skills,
|
||||
key: "skills",
|
||||
// stage: Stage::Beta {
|
||||
// menu_description: "Define new `skills` for the model",
|
||||
// announcement: "NEW! Try the new `skills` features. Enable in /experimental!",
|
||||
// },
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::UnifiedExec,
|
||||
key: "unified_exec",
|
||||
// stage: Stage::Beta {
|
||||
// menu_description: "Run long-running terminal commands in the background.",
|
||||
// announcement: "NEW! Try Background terminals for long running processes. Enable in /experimental!",
|
||||
// },
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellSnapshot,
|
||||
key: "shell_snapshot",
|
||||
// stage: Stage::Beta {
|
||||
// menu_description: "Snapshot your shell environment to avoid re-running login scripts for every command.",
|
||||
// announcement: "NEW! Try shell snapshotting to make your Codex faster. Enable in /experimental!",
|
||||
// },
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
// Unstable features.
|
||||
FeatureSpec {
|
||||
id: Feature::RmcpClient,
|
||||
key: "rmcp_client",
|
||||
@@ -308,13 +362,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::ApplyPatchFreeform,
|
||||
key: "apply_patch_freeform",
|
||||
stage: Stage::Beta,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::WebSearchRequest,
|
||||
key: "web_search_request",
|
||||
stage: Stage::Stable,
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
@@ -347,12 +395,6 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Skills,
|
||||
key: "skills",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellSnapshot,
|
||||
key: "shell_snapshot",
|
||||
@@ -365,4 +407,10 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::NetworkProxy,
|
||||
key: "network_proxy",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,6 +41,7 @@ mod mcp_tool_call;
|
||||
mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod parse_command;
|
||||
pub mod path_utils;
|
||||
pub mod powershell;
|
||||
pub mod sandboxing;
|
||||
mod stream_events_utils;
|
||||
@@ -69,6 +70,7 @@ pub use conversation_manager::NewConversation;
|
||||
pub use auth::AuthManager;
|
||||
pub use auth::CodexAuth;
|
||||
pub mod default_client;
|
||||
pub mod network_proxy;
|
||||
pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
|
||||
@@ -398,7 +398,7 @@ impl McpConnectionManager {
|
||||
|
||||
/// Returns a single map that contains all tools. Each key is the
|
||||
/// fully-qualified name for the tool.
|
||||
#[instrument(skip_all)]
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub async fn list_all_tools(&self) -> HashMap<String, ToolInfo> {
|
||||
let mut tools = HashMap::new();
|
||||
for managed_client in self.clients.values() {
|
||||
|
||||
900
codex-rs/core/src/network_proxy.rs
Normal file
900
codex-rs/core/src/network_proxy.rs
Normal file
@@ -0,0 +1,900 @@
|
||||
use crate::config;
|
||||
use crate::config::types::NetworkProxyConfig;
|
||||
use crate::config::types::NetworkProxyMode;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_client::CodexHttpClient;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use shlex::split as shlex_split;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::InlineTable;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
use wildmatch::WildMatchPattern;
|
||||
|
||||
const NETWORK_PROXY_TABLE: &str = "network_proxy";
|
||||
const NETWORK_PROXY_POLICY_TABLE: &str = "policy";
|
||||
const ALLOWED_DOMAINS_KEY: &str = "allowed_domains";
|
||||
const DENIED_DOMAINS_KEY: &str = "denied_domains";
|
||||
const ALLOW_UNIX_SOCKETS_KEY: &str = "allow_unix_sockets";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NetworkProxyBlockedRequest {
|
||||
pub host: String,
|
||||
pub reason: String,
|
||||
#[serde(default)]
|
||||
pub call_id: Option<String>,
|
||||
pub client: Option<String>,
|
||||
pub method: Option<String>,
|
||||
pub mode: Option<NetworkProxyMode>,
|
||||
pub protocol: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BlockedResponse {
|
||||
blocked: Vec<NetworkProxyBlockedRequest>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ModeUpdate {
|
||||
mode: NetworkProxyMode,
|
||||
}
|
||||
|
||||
pub async fn fetch_blocked(
|
||||
client: &CodexHttpClient,
|
||||
admin_url: &str,
|
||||
) -> Result<Vec<NetworkProxyBlockedRequest>> {
|
||||
let base = admin_url.trim_end_matches('/');
|
||||
let url = format!("{base}/blocked");
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("network proxy /blocked request failed")?
|
||||
.error_for_status()
|
||||
.context("network proxy /blocked returned error")?;
|
||||
let payload: BlockedResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("network proxy /blocked returned invalid JSON")?;
|
||||
Ok(payload.blocked)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct NetworkApprovalKey {
|
||||
host: String,
|
||||
}
|
||||
|
||||
impl NetworkApprovalKey {
|
||||
fn new(host: &str) -> Option<Self> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
host: host.to_ascii_lowercase(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cache_network_approval(
|
||||
store: &mut ApprovalStore,
|
||||
host: &str,
|
||||
decision: ReviewDecision,
|
||||
) -> bool {
|
||||
if !matches!(
|
||||
decision,
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = NetworkApprovalKey::new(host) else {
|
||||
return false;
|
||||
};
|
||||
store.put(key, decision);
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn set_mode(
|
||||
client: &CodexHttpClient,
|
||||
admin_url: &str,
|
||||
mode: NetworkProxyMode,
|
||||
) -> Result<()> {
|
||||
let base = admin_url.trim_end_matches('/');
|
||||
let url = format!("{base}/mode");
|
||||
let request = ModeUpdate { mode };
|
||||
client
|
||||
.post(url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.context("network proxy /mode request failed")?
|
||||
.error_for_status()
|
||||
.context("network proxy /mode returned error")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload(client: &CodexHttpClient, admin_url: &str) -> Result<()> {
|
||||
let base = admin_url.trim_end_matches('/');
|
||||
let url = format!("{base}/reload");
|
||||
client
|
||||
.post(url)
|
||||
.send()
|
||||
.await
|
||||
.context("network proxy /reload request failed")?
|
||||
.error_for_status()
|
||||
.context("network proxy /reload returned error")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_allowed_domain(config_path: &Path, host: &str) -> Result<bool> {
|
||||
update_domain_list(config_path, host, DomainListKind::Allow)
|
||||
}
|
||||
|
||||
pub fn add_denied_domain(config_path: &Path, host: &str) -> Result<bool> {
|
||||
update_domain_list(config_path, host, DomainListKind::Deny)
|
||||
}
|
||||
|
||||
pub fn add_allowed_unix_socket(config_path: &Path, socket: &str) -> Result<bool> {
|
||||
update_unix_socket_list(config_path, socket, UnixSocketListKind::Allow)
|
||||
}
|
||||
|
||||
pub fn remove_allowed_unix_socket(config_path: &Path, socket: &str) -> Result<bool> {
|
||||
update_unix_socket_list(config_path, socket, UnixSocketListKind::Remove)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DomainState {
|
||||
pub allowed: bool,
|
||||
pub denied: bool,
|
||||
}
|
||||
|
||||
pub fn domain_state(config_path: &Path, host: &str) -> Result<DomainState> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(anyhow!("host is empty"));
|
||||
}
|
||||
let policy = load_network_policy(config_path)?;
|
||||
Ok(DomainState {
|
||||
allowed: list_contains(&policy.allowed_domains, host),
|
||||
denied: list_contains(&policy.denied_domains, host),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_domain_state(config_path: &Path, host: &str, state: DomainState) -> Result<bool> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(anyhow!("host is empty"));
|
||||
}
|
||||
let mut doc = load_document(config_path)?;
|
||||
let policy = ensure_policy_table(&mut doc);
|
||||
let mut changed = false;
|
||||
{
|
||||
let allowed = ensure_array(policy, ALLOWED_DOMAINS_KEY);
|
||||
if state.allowed {
|
||||
changed |= add_domain(allowed, host);
|
||||
} else {
|
||||
changed |= remove_domain(allowed, host);
|
||||
}
|
||||
}
|
||||
{
|
||||
let denied = ensure_array(policy, DENIED_DOMAINS_KEY);
|
||||
if state.denied {
|
||||
changed |= add_domain(denied, host);
|
||||
} else {
|
||||
changed |= remove_domain(denied, host);
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
write_document(config_path, &doc)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn unix_socket_allowed(config_path: &Path, socket_path: &Path) -> Result<bool> {
|
||||
let policy = load_network_policy(config_path)?;
|
||||
let allowed = resolve_unix_socket_allowlist(&policy.allow_unix_sockets);
|
||||
if allowed.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let canonical_socket = socket_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| socket_path.to_path_buf());
|
||||
Ok(allowed
|
||||
.iter()
|
||||
.any(|allowed_path| canonical_socket.starts_with(allowed_path)))
|
||||
}
|
||||
|
||||
pub fn should_preflight_network(
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> bool {
|
||||
if !network_proxy.enabled {
|
||||
return false;
|
||||
}
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
|
||||
SandboxPolicy::DangerFullAccess => true,
|
||||
SandboxPolicy::ReadOnly => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preflight_blocked_host_if_enabled(
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
) -> Result<Option<PreflightMatch>> {
|
||||
if !should_preflight_network(network_proxy, sandbox_policy) {
|
||||
return Ok(None);
|
||||
}
|
||||
let config_path = config::default_config_path()?;
|
||||
preflight_blocked_host(&config_path, command)
|
||||
}
|
||||
|
||||
pub fn preflight_blocked_request_if_enabled(
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
) -> Result<Option<NetworkProxyBlockedRequest>> {
|
||||
match preflight_blocked_host_if_enabled(network_proxy, sandbox_policy, command)? {
|
||||
Some(hit) => Ok(Some(NetworkProxyBlockedRequest {
|
||||
host: hit.host,
|
||||
reason: hit.reason,
|
||||
call_id: None,
|
||||
client: None,
|
||||
method: None,
|
||||
mode: Some(network_proxy.mode),
|
||||
protocol: "preflight".to_string(),
|
||||
timestamp: 0,
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnixSocketPreflightMatch {
|
||||
/// Socket path that needs to be allowed (canonicalized when possible).
|
||||
pub socket_path: PathBuf,
|
||||
/// Suggested config entry to add for a persistent allow (e.g. `$SSH_AUTH_SOCK`).
|
||||
pub suggested_allow_entry: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn preflight_blocked_unix_socket_if_enabled(
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
) -> Result<Option<UnixSocketPreflightMatch>> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Ok(None);
|
||||
}
|
||||
if !should_preflight_network(network_proxy, sandbox_policy) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(socket_path) = ssh_auth_sock_if_needed(command) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let config_path = config::default_config_path()?;
|
||||
if unix_socket_allowed(&config_path, &socket_path)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(UnixSocketPreflightMatch {
|
||||
socket_path,
|
||||
suggested_allow_entry: "$SSH_AUTH_SOCK".to_string(),
|
||||
reason: "not_allowed_unix_socket".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn apply_mitm_ca_env_if_enabled(
|
||||
env_map: &mut HashMap<String, String>,
|
||||
network_proxy: &NetworkProxyConfig,
|
||||
) {
|
||||
let Some(ca_cert_path) = network_proxy.mitm_ca_cert_path.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ca_value = ca_cert_path.to_string_lossy().to_string();
|
||||
for key in [
|
||||
"SSL_CERT_FILE",
|
||||
"CURL_CA_BUNDLE",
|
||||
"GIT_SSL_CAINFO",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"PIP_CERT",
|
||||
"NPM_CONFIG_CAFILE",
|
||||
"npm_config_cafile",
|
||||
"CODEX_PROXY_CERT",
|
||||
"PROXY_CA_CERT_PATH",
|
||||
] {
|
||||
env_map
|
||||
.entry(key.to_string())
|
||||
.or_insert_with(|| ca_value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proxy_host_port(proxy_url: &str) -> Option<(String, i64)> {
|
||||
let trimmed = proxy_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let without_scheme = trimmed
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(trimmed);
|
||||
let mut host_port = without_scheme.split('/').next().unwrap_or("");
|
||||
if let Some((_, rest)) = host_port.rsplit_once('@') {
|
||||
host_port = rest;
|
||||
}
|
||||
if host_port.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (host, port_str) = if host_port.starts_with('[') {
|
||||
let end = host_port.find(']')?;
|
||||
let host = &host_port[1..end];
|
||||
let port = host_port[end + 1..].strip_prefix(':')?;
|
||||
(host, port)
|
||||
} else {
|
||||
host_port.rsplit_once(':')?
|
||||
};
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let port: i64 = port_str.parse().ok()?;
|
||||
if port <= 0 {
|
||||
return None;
|
||||
}
|
||||
Some((host.to_string(), port))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreflightMatch {
|
||||
pub host: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn preflight_blocked_host(
|
||||
config_path: &Path,
|
||||
command: &[String],
|
||||
) -> Result<Option<PreflightMatch>> {
|
||||
let policy = load_network_policy(config_path)?;
|
||||
let hosts = extract_hosts_from_command(command);
|
||||
for host in hosts {
|
||||
if policy
|
||||
.denied_domains
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host))
|
||||
{
|
||||
return Ok(Some(PreflightMatch {
|
||||
host,
|
||||
reason: "denied".to_string(),
|
||||
}));
|
||||
}
|
||||
if policy.allowed_domains.is_empty()
|
||||
|| !policy
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host))
|
||||
{
|
||||
return Ok(Some(PreflightMatch {
|
||||
host,
|
||||
reason: "not_allowed".to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn preflight_host(config_path: &Path, host: &str) -> Result<Option<String>> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(anyhow!("host is empty"));
|
||||
}
|
||||
let policy = load_network_policy(config_path)?;
|
||||
if policy
|
||||
.denied_domains
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, host))
|
||||
{
|
||||
return Ok(Some("denied".to_string()));
|
||||
}
|
||||
if policy.allowed_domains.is_empty()
|
||||
|| !policy
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, host))
|
||||
{
|
||||
return Ok(Some("not_allowed".to_string()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum DomainListKind {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
fn update_domain_list(config_path: &Path, host: &str, list: DomainListKind) -> Result<bool> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(anyhow!("host is empty"));
|
||||
}
|
||||
let mut doc = load_document(config_path)?;
|
||||
let policy = ensure_policy_table(&mut doc);
|
||||
let (target_key, other_key) = match list {
|
||||
DomainListKind::Allow => (ALLOWED_DOMAINS_KEY, DENIED_DOMAINS_KEY),
|
||||
DomainListKind::Deny => (DENIED_DOMAINS_KEY, ALLOWED_DOMAINS_KEY),
|
||||
};
|
||||
|
||||
let mut changed = {
|
||||
let target = ensure_array(policy, target_key);
|
||||
add_domain(target, host)
|
||||
};
|
||||
let removed = {
|
||||
let other = ensure_array(policy, other_key);
|
||||
remove_domain(other, host)
|
||||
};
|
||||
if removed {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
write_document(config_path, &doc)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum UnixSocketListKind {
|
||||
Allow,
|
||||
Remove,
|
||||
}
|
||||
|
||||
fn update_unix_socket_list(
|
||||
config_path: &Path,
|
||||
socket: &str,
|
||||
action: UnixSocketListKind,
|
||||
) -> Result<bool> {
|
||||
let socket = socket.trim();
|
||||
if socket.is_empty() {
|
||||
return Err(anyhow!("socket is empty"));
|
||||
}
|
||||
let mut doc = load_document(config_path)?;
|
||||
let policy = ensure_policy_table(&mut doc);
|
||||
let list = ensure_array(policy, ALLOW_UNIX_SOCKETS_KEY);
|
||||
let changed = match action {
|
||||
UnixSocketListKind::Allow => add_domain(list, socket),
|
||||
UnixSocketListKind::Remove => remove_domain(list, socket),
|
||||
};
|
||||
if changed {
|
||||
write_document(config_path, &doc)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn load_document(path: &Path) -> Result<DocumentMut> {
|
||||
if !path.exists() {
|
||||
return Ok(DocumentMut::new());
|
||||
}
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read network proxy config at {}", path.display()))?;
|
||||
raw.parse::<DocumentMut>()
|
||||
.with_context(|| format!("failed to parse network proxy config at {}", path.display()))
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct NetworkPolicyConfig {
|
||||
#[serde(default, rename = "network_proxy")]
|
||||
network_proxy: NetworkProxySection,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct NetworkProxySection {
|
||||
#[serde(default)]
|
||||
policy: NetworkPolicy,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
pub(crate) struct NetworkPolicy {
|
||||
#[serde(default, rename = "allowed_domains", alias = "allowedDomains")]
|
||||
allowed_domains: Vec<String>,
|
||||
#[serde(default, rename = "denied_domains", alias = "deniedDomains")]
|
||||
denied_domains: Vec<String>,
|
||||
#[serde(default, rename = "allow_unix_sockets", alias = "allowUnixSockets")]
|
||||
pub(crate) allow_unix_sockets: Vec<String>,
|
||||
#[serde(default, rename = "allow_local_binding", alias = "allowLocalBinding")]
|
||||
pub(crate) allow_local_binding: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_unix_socket_allowlist(entries: &[String]) -> Vec<PathBuf> {
|
||||
let mut resolved = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for candidate in resolve_unix_socket_entry(entry) {
|
||||
if !seen.insert(candidate.clone()) {
|
||||
continue;
|
||||
}
|
||||
resolved.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
resolved.sort();
|
||||
resolved
|
||||
}
|
||||
|
||||
fn resolve_unix_socket_entry(entry: &str) -> Vec<PathBuf> {
|
||||
// Presets are intentionally simple: they resolve to a path (or set of paths)
|
||||
// and are ultimately translated into Seatbelt `subpath` rules.
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
match entry {
|
||||
"ssh-agent" | "ssh_auth_sock" | "ssh_auth_socket" => {
|
||||
if let Some(value) = std::env::var_os("SSH_AUTH_SOCK") {
|
||||
candidates.push(value.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(var) = entry.strip_prefix('$') {
|
||||
candidates.extend(resolve_env_unix_socket(var));
|
||||
} else if entry.starts_with("${") && entry.ends_with('}') {
|
||||
candidates.extend(resolve_env_unix_socket(&entry[2..entry.len() - 1]));
|
||||
} else {
|
||||
candidates.push(entry.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(|candidate| parse_unix_socket_candidate(&candidate))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_env_unix_socket(var: &str) -> Vec<String> {
|
||||
let var = var.trim();
|
||||
if var.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
std::env::var_os(var)
|
||||
.map(|value| vec![value.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_unix_socket_candidate(candidate: &str) -> Option<PathBuf> {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = if let Some(rest) = trimmed.strip_prefix("unix://") {
|
||||
rest
|
||||
} else if let Some(rest) = trimmed.strip_prefix("unix:") {
|
||||
rest
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
let path = PathBuf::from(path);
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
Some(path.canonicalize().unwrap_or(path))
|
||||
}
|
||||
|
||||
pub(crate) fn load_network_policy(config_path: &Path) -> Result<NetworkPolicy> {
|
||||
if !config_path.exists() {
|
||||
return Ok(NetworkPolicy::default());
|
||||
}
|
||||
let raw = std::fs::read_to_string(config_path).with_context(|| {
|
||||
format!(
|
||||
"failed to read network proxy config at {}",
|
||||
config_path.display()
|
||||
)
|
||||
})?;
|
||||
let config: NetworkPolicyConfig = toml::from_str(&raw).with_context(|| {
|
||||
format!(
|
||||
"failed to parse network proxy config at {}",
|
||||
config_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(config.network_proxy.policy)
|
||||
}
|
||||
|
||||
fn list_contains(domains: &[String], host: &str) -> bool {
|
||||
domains.iter().any(|value| value.eq_ignore_ascii_case(host))
|
||||
}
|
||||
|
||||
fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
let pattern = pattern.trim();
|
||||
if pattern.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let matcher: WildMatchPattern<'*', '?'> = WildMatchPattern::new_case_insensitive(pattern);
|
||||
if matcher.matches(host) {
|
||||
return true;
|
||||
}
|
||||
if let Some(apex) = pattern.strip_prefix("*.") {
|
||||
return apex.eq_ignore_ascii_case(host);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn extract_hosts_from_command(command: &[String]) -> Vec<String> {
|
||||
let mut hosts = HashSet::new();
|
||||
extract_hosts_from_tokens(command, &mut hosts);
|
||||
for tokens in extract_shell_script_commands(command) {
|
||||
extract_hosts_from_tokens(&tokens, &mut hosts);
|
||||
}
|
||||
hosts.into_iter().collect()
|
||||
}
|
||||
|
||||
fn ssh_auth_sock_if_needed(command: &[String]) -> Option<PathBuf> {
|
||||
let Some(cmd0) = command.first() else {
|
||||
return None;
|
||||
};
|
||||
let cmd = std::path::Path::new(cmd0)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
let needs_sock = match cmd {
|
||||
"ssh" | "scp" | "sftp" | "ssh-add" => true,
|
||||
"git" => command
|
||||
.iter()
|
||||
.skip(1)
|
||||
.any(|arg| arg.contains("ssh://") || looks_like_scp_host(arg)),
|
||||
_ => false,
|
||||
};
|
||||
if !needs_sock {
|
||||
return None;
|
||||
}
|
||||
let sock = std::env::var_os("SSH_AUTH_SOCK")?;
|
||||
let sock = sock.to_string_lossy().to_string();
|
||||
parse_unix_socket_candidate(&sock)
|
||||
}
|
||||
|
||||
fn looks_like_scp_host(value: &str) -> bool {
|
||||
// e.g. git@github.com:owner/repo.git
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.starts_with('-') {
|
||||
return false;
|
||||
}
|
||||
value.contains('@') && value.contains(':') && !value.contains("://")
|
||||
}
|
||||
|
||||
fn extract_hosts_from_tokens(tokens: &[String], hosts: &mut HashSet<String>) {
|
||||
let (cmd0, args) = match tokens.split_first() {
|
||||
Some((cmd0, args)) => (cmd0.as_str(), args),
|
||||
None => return,
|
||||
};
|
||||
let cmd = std::path::Path::new(cmd0)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
let (_tool, tool_args) = match cmd {
|
||||
"curl" | "wget" | "git" | "gh" | "ssh" | "scp" | "rsync" => (cmd, args),
|
||||
"npm" | "yarn" | "pnpm" | "pip" | "pip3" | "pipx" | "cargo" | "go" => (cmd, args),
|
||||
"python" | "python3"
|
||||
if matches!(
|
||||
(args.first(), args.get(1)),
|
||||
(Some(flag), Some(module)) if flag == "-m" && module == "pip"
|
||||
) =>
|
||||
{
|
||||
("pip", &args[2..])
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if tool_args.is_empty() {
|
||||
return;
|
||||
}
|
||||
for arg in tool_args {
|
||||
if let Some(host) = extract_host_from_url(arg) {
|
||||
hosts.insert(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_shell_script_commands(command: &[String]) -> Vec<Vec<String>> {
|
||||
let Some(cmd0) = command.first() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let cmd = std::path::Path::new(cmd0)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
if !matches!(cmd, "bash" | "zsh" | "sh") {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(flag) = command.get(1) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !matches!(flag.as_str(), "-lc" | "-c") {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(script) = command.get(2) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let tokens = shlex_split(script)
|
||||
.unwrap_or_else(|| script.split_whitespace().map(ToString::to_string).collect());
|
||||
split_shell_tokens_into_commands(&tokens)
|
||||
}
|
||||
|
||||
fn split_shell_tokens_into_commands(tokens: &[String]) -> Vec<Vec<String>> {
|
||||
let mut commands = Vec::new();
|
||||
let mut current: Vec<String> = Vec::new();
|
||||
for token in tokens {
|
||||
if is_shell_separator(token) {
|
||||
if !current.is_empty() {
|
||||
commands.push(std::mem::take(&mut current));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push(token.clone());
|
||||
}
|
||||
if !current.is_empty() {
|
||||
commands.push(current);
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn is_shell_separator(token: &str) -> bool {
|
||||
matches!(token, "&&" | "||" | ";" | "|")
|
||||
}
|
||||
|
||||
fn extract_host_from_url(value: &str) -> Option<String> {
|
||||
let trimmed = value
|
||||
.trim()
|
||||
.trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ';' | ','));
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for scheme in ["http://", "https://", "ssh://", "git://", "git+ssh://"] {
|
||||
if let Some(rest) = trimmed.strip_prefix(scheme) {
|
||||
return normalize_host(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_host(value: &str) -> Option<String> {
|
||||
let mut host = value.split('/').next().unwrap_or("");
|
||||
if let Some((_, tail)) = host.rsplit_once('@') {
|
||||
host = tail;
|
||||
}
|
||||
if let Some((head, _)) = host.split_once(':') {
|
||||
host = head;
|
||||
}
|
||||
let host = host.trim_matches(|c: char| matches!(c, '.' | ',' | ';'));
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_document(path: &Path, doc: &DocumentMut) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
let mut output = doc.to_string();
|
||||
if !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
std::fs::write(path, output)
|
||||
.with_context(|| format!("failed to write network proxy config at {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_network_proxy_table(doc: &mut DocumentMut) -> &mut TomlTable {
|
||||
let entry = doc
|
||||
.entry(NETWORK_PROXY_TABLE)
|
||||
.or_insert_with(|| TomlItem::Table(TomlTable::new()));
|
||||
let table = ensure_table_for_write(entry);
|
||||
table.set_implicit(false);
|
||||
table
|
||||
}
|
||||
|
||||
fn ensure_policy_table(doc: &mut DocumentMut) -> &mut TomlTable {
|
||||
let network_proxy = ensure_network_proxy_table(doc);
|
||||
let entry = network_proxy
|
||||
.entry(NETWORK_PROXY_POLICY_TABLE)
|
||||
.or_insert_with(|| TomlItem::Table(TomlTable::new()));
|
||||
let table = ensure_table_for_write(entry);
|
||||
table.set_implicit(false);
|
||||
table
|
||||
}
|
||||
|
||||
fn ensure_table_for_write(item: &mut TomlItem) -> &mut TomlTable {
|
||||
loop {
|
||||
match item {
|
||||
TomlItem::Table(table) => return table,
|
||||
TomlItem::Value(value) => {
|
||||
if let Some(inline) = value.as_inline_table() {
|
||||
*item = TomlItem::Table(table_from_inline(inline));
|
||||
} else {
|
||||
*item = TomlItem::Table(TomlTable::new());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
*item = TomlItem::Table(TomlTable::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn table_from_inline(inline: &InlineTable) -> TomlTable {
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(false);
|
||||
for (key, value) in inline.iter() {
|
||||
table.insert(key, TomlItem::Value(value.clone()));
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
fn ensure_array<'a>(table: &'a mut TomlTable, key: &str) -> &'a mut TomlArray {
|
||||
let entry = table
|
||||
.entry(key)
|
||||
.or_insert_with(|| TomlItem::Value(TomlArray::new().into()));
|
||||
if entry.as_array().is_none() {
|
||||
*entry = TomlItem::Value(TomlArray::new().into());
|
||||
}
|
||||
match entry {
|
||||
TomlItem::Value(value) => value
|
||||
.as_array_mut()
|
||||
.unwrap_or_else(|| unreachable!("array should exist after normalization")),
|
||||
_ => unreachable!("array should be a value after normalization"),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_domain(array: &mut TomlArray, host: &str) -> bool {
|
||||
if array
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.any(|existing| existing.eq_ignore_ascii_case(host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
array.push(host);
|
||||
true
|
||||
}
|
||||
|
||||
fn remove_domain(array: &mut TomlArray, host: &str) -> bool {
|
||||
let mut removed = false;
|
||||
let mut updated = TomlArray::new();
|
||||
for item in array.iter() {
|
||||
let should_remove = item
|
||||
.as_str()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(host));
|
||||
if should_remove {
|
||||
removed = true;
|
||||
} else {
|
||||
updated.push(item.clone());
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
*array = updated;
|
||||
}
|
||||
removed
|
||||
}
|
||||
@@ -110,7 +110,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::High,
|
||||
description: "Greater reasoning depth for complex or ambiguous problems".to_string(),
|
||||
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::XHigh,
|
||||
|
||||
@@ -51,7 +51,7 @@ impl ModelsManager {
|
||||
let codex_home = auth_manager.codex_home().to_path_buf();
|
||||
Self {
|
||||
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
|
||||
remote_models: RwLock::new(Vec::new()),
|
||||
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
|
||||
auth_manager,
|
||||
etag: RwLock::new(None),
|
||||
codex_home,
|
||||
@@ -66,7 +66,7 @@ impl ModelsManager {
|
||||
let codex_home = auth_manager.codex_home().to_path_buf();
|
||||
Self {
|
||||
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
|
||||
remote_models: RwLock::new(Vec::new()),
|
||||
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
|
||||
auth_manager,
|
||||
etag: RwLock::new(None),
|
||||
codex_home,
|
||||
@@ -77,7 +77,9 @@ impl ModelsManager {
|
||||
|
||||
/// Fetch the latest remote models, using the on-disk cache when still fresh.
|
||||
pub async fn refresh_available_models(&self, config: &Config) -> CoreResult<()> {
|
||||
if !config.features.enabled(Feature::RemoteModels) {
|
||||
if !config.features.enabled(Feature::RemoteModels)
|
||||
|| self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if self.try_load_cache().await {
|
||||
@@ -165,6 +167,12 @@ impl ModelsManager {
|
||||
*self.remote_models.write().await = models;
|
||||
}
|
||||
|
||||
fn load_remote_models_from_file() -> Result<Vec<ModelInfo>, std::io::Error> {
|
||||
let file_contents = include_str!("../../models.json");
|
||||
let response: ModelsResponse = serde_json::from_str(file_contents)?;
|
||||
Ok(response.models)
|
||||
}
|
||||
|
||||
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
|
||||
async fn try_load_cache(&self) -> bool {
|
||||
// todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk
|
||||
@@ -377,7 +385,7 @@ mod tests {
|
||||
.expect("load default test config");
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let provider = provider_for(server.uri());
|
||||
let manager = ModelsManager::with_provider(auth_manager, provider);
|
||||
|
||||
@@ -567,7 +575,7 @@ mod tests {
|
||||
.expect("load default test config");
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let provider = provider_for(server.uri());
|
||||
let mut manager = ModelsManager::with_provider(auth_manager, provider);
|
||||
manager.cache_ttl = Duration::ZERO;
|
||||
@@ -634,4 +642,25 @@ mod tests {
|
||||
|
||||
assert_eq!(available, vec![expected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_models_json_roundtrips() {
|
||||
let file_contents = include_str!("../../models.json");
|
||||
let response: ModelsResponse =
|
||||
serde_json::from_str(file_contents).expect("bundled models.json should deserialize");
|
||||
|
||||
let serialized =
|
||||
serde_json::to_string(&response).expect("bundled models.json should serialize");
|
||||
let roundtripped: ModelsResponse =
|
||||
serde_json::from_str(&serialized).expect("serialized models.json should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
response, roundtripped,
|
||||
"bundled models.json should round trip through serde"
|
||||
);
|
||||
assert!(
|
||||
!response.models.is_empty(),
|
||||
"bundled models.json should contain at least one model"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
116
codex-rs/core/src/path_utils.rs
Normal file
116
codex-rs/core/src/path_utils.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::env;
|
||||
|
||||
pub fn normalize_for_path_comparison(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let canonical = path.canonicalize()?;
|
||||
Ok(normalize_for_wsl(canonical))
|
||||
}
|
||||
|
||||
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
|
||||
normalize_for_wsl_with_flag(path, env::is_wsl())
|
||||
}
|
||||
|
||||
fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf {
|
||||
if !is_wsl {
|
||||
return path;
|
||||
}
|
||||
|
||||
if !is_wsl_case_insensitive_path(&path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
lower_ascii_path(path)
|
||||
}
|
||||
|
||||
fn is_wsl_case_insensitive_path(path: &Path) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::Component;
|
||||
|
||||
let mut components = path.components();
|
||||
let Some(Component::RootDir) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(Component::Normal(mnt)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") {
|
||||
return false;
|
||||
}
|
||||
let Some(Component::Normal(drive)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let drive_bytes = drive.as_bytes();
|
||||
drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.zip(right)
|
||||
.all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lower_ascii_path(path: PathBuf) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
// WSL mounts Windows drives under /mnt/<drive>, which are case-insensitive.
|
||||
let bytes = path.as_os_str().as_bytes();
|
||||
let mut lowered = Vec::with_capacity(bytes.len());
|
||||
for byte in bytes {
|
||||
lowered.push(byte.to_ascii_lowercase());
|
||||
}
|
||||
PathBuf::from(OsString::from_vec(lowered))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn lower_ascii_path(path: PathBuf) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wsl {
|
||||
use super::super::normalize_for_wsl_with_flag;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn wsl_mnt_drive_paths_lowercase() {
|
||||
let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true);
|
||||
|
||||
assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_non_drive_paths_unchanged() {
|
||||
let path = PathBuf::from("/mnt/cc/Users/Dev");
|
||||
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
|
||||
|
||||
assert_eq!(normalized, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_non_mnt_paths_unchanged() {
|
||||
let path = PathBuf::from("/home/Dev");
|
||||
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
|
||||
|
||||
assert_eq!(normalized, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,7 +522,7 @@ mod tests {
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let expected = format!(
|
||||
"base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}"
|
||||
"base doc\n\n## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
@@ -546,7 +546,7 @@ mod tests {
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let expected = format!(
|
||||
"## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}"
|
||||
"## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_) => false,
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::SkillsUpdateAvailable => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ impl SandboxManager {
|
||||
let mut seatbelt_env = HashMap::new();
|
||||
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
let mut args =
|
||||
create_seatbelt_command_args(command.clone(), policy, sandbox_policy_cwd);
|
||||
create_seatbelt_command_args(command.clone(), policy, sandbox_policy_cwd, &env);
|
||||
let mut full_command = Vec::with_capacity(1 + args.len());
|
||||
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
|
||||
full_command.append(&mut args);
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::CStr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
|
||||
use crate::config;
|
||||
use crate::network_proxy;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
|
||||
const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
|
||||
const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
|
||||
const MACOS_SEATBELT_NETWORK_POLICY_BASE: &str = include_str!("seatbelt_network_policy.sbpl");
|
||||
const PROXY_ENV_KEYS: &[&str] = &[
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
];
|
||||
|
||||
/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
|
||||
/// to defend against an attacker trying to inject a malicious version on the
|
||||
@@ -28,7 +39,7 @@ pub async fn spawn_command_under_seatbelt(
|
||||
stdio_policy: StdioPolicy,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd);
|
||||
let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd, &env);
|
||||
let arg0 = None;
|
||||
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
spawn_child_async(
|
||||
@@ -43,10 +54,133 @@ pub async fn spawn_command_under_seatbelt(
|
||||
.await
|
||||
}
|
||||
|
||||
fn is_loopback_host(host: &str) -> bool {
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
host_lower == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProxyPorts {
|
||||
http: Vec<u16>,
|
||||
socks: Vec<u16>,
|
||||
has_proxy_env: bool,
|
||||
has_non_loopback_proxy_env: bool,
|
||||
}
|
||||
|
||||
fn proxy_ports_from_env(env: &HashMap<String, String>) -> ProxyPorts {
|
||||
let mut http_ports = BTreeSet::new();
|
||||
let mut socks_ports = BTreeSet::new();
|
||||
let mut has_proxy_env = false;
|
||||
let mut has_non_loopback_proxy_env = false;
|
||||
|
||||
for key in PROXY_ENV_KEYS {
|
||||
let Some(proxy_url) = env.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
has_proxy_env = true;
|
||||
let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else {
|
||||
continue;
|
||||
};
|
||||
let Some(port) = normalize_proxy_port(port) else {
|
||||
continue;
|
||||
};
|
||||
if !is_loopback_host(&host) {
|
||||
has_non_loopback_proxy_env = true;
|
||||
continue;
|
||||
}
|
||||
let scheme = proxy_url_scheme(proxy_url).unwrap_or("http");
|
||||
if scheme.to_ascii_lowercase().starts_with("socks") {
|
||||
socks_ports.insert(port);
|
||||
} else {
|
||||
http_ports.insert(port);
|
||||
}
|
||||
}
|
||||
|
||||
ProxyPorts {
|
||||
http: http_ports.into_iter().collect(),
|
||||
socks: socks_ports.into_iter().collect(),
|
||||
has_proxy_env,
|
||||
has_non_loopback_proxy_env,
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_url_scheme(proxy_url: &str) -> Option<&str> {
|
||||
proxy_url.split_once("://").map(|(scheme, _)| scheme)
|
||||
}
|
||||
|
||||
fn normalize_proxy_port(port: i64) -> Option<u16> {
|
||||
if (1..=u16::MAX as i64).contains(&port) {
|
||||
Some(port as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_sbpl_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn build_network_policy(policy: &network_proxy::NetworkPolicy, proxy_ports: &ProxyPorts) -> String {
|
||||
let mut network_rules = String::from("; Network\n");
|
||||
// On macOS, `sandbox-exec` only accepts `localhost` or `*` in network
|
||||
// addresses. We use loopback proxy ports + the network proxy itself to
|
||||
// enforce per-domain policy and prompting.
|
||||
if !proxy_ports.has_proxy_env {
|
||||
network_rules.push_str("(allow network*)\n");
|
||||
return format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}");
|
||||
}
|
||||
|
||||
if policy.allow_local_binding {
|
||||
network_rules.push_str("(allow network-bind (local ip \"localhost:*\"))\n");
|
||||
network_rules.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
|
||||
network_rules.push_str("(allow network-outbound (local ip \"localhost:*\"))\n");
|
||||
}
|
||||
|
||||
if !policy.allow_unix_sockets.is_empty() {
|
||||
for socket_path in network_proxy::resolve_unix_socket_allowlist(&policy.allow_unix_sockets)
|
||||
{
|
||||
let escaped = escape_sbpl_string(&socket_path.to_string_lossy());
|
||||
network_rules.push_str(&format!("(allow network* (subpath \"{escaped}\"))\n"));
|
||||
}
|
||||
}
|
||||
|
||||
for port in &proxy_ports.http {
|
||||
network_rules.push_str(&format!(
|
||||
"(allow network-bind (local ip \"localhost:{port}\"))\n"
|
||||
));
|
||||
network_rules.push_str(&format!(
|
||||
"(allow network-inbound (local ip \"localhost:{port}\"))\n"
|
||||
));
|
||||
network_rules.push_str(&format!(
|
||||
"(allow network-outbound (remote ip \"localhost:{port}\"))\n"
|
||||
));
|
||||
}
|
||||
|
||||
for port in &proxy_ports.socks {
|
||||
network_rules.push_str(&format!(
|
||||
"(allow network-bind (local ip \"localhost:{port}\"))\n"
|
||||
));
|
||||
network_rules.push_str(&format!(
|
||||
"(allow network-inbound (local ip \"localhost:{port}\"))\n"
|
||||
));
|
||||
network_rules.push_str(&format!(
|
||||
"(allow network-outbound (remote ip \"localhost:{port}\"))\n"
|
||||
));
|
||||
}
|
||||
|
||||
if proxy_ports.has_non_loopback_proxy_env {
|
||||
network_rules
|
||||
.push_str("; NOTE: Non-loopback proxies are not supported under `sandbox-exec`.\n");
|
||||
}
|
||||
|
||||
format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}")
|
||||
}
|
||||
|
||||
pub(crate) fn create_seatbelt_command_args(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
env: &HashMap<String, String>,
|
||||
) -> Vec<String> {
|
||||
let (file_write_policy, file_write_dir_params) = {
|
||||
if sandbox_policy.has_full_disk_write_access() {
|
||||
@@ -113,9 +247,14 @@ pub(crate) fn create_seatbelt_command_args(
|
||||
|
||||
// TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
|
||||
let network_policy = if sandbox_policy.has_full_network_access() {
|
||||
MACOS_SEATBELT_NETWORK_POLICY
|
||||
let proxy_ports = proxy_ports_from_env(env);
|
||||
let policy = config::default_config_path()
|
||||
.ok()
|
||||
.and_then(|path| network_proxy::load_network_policy(&path).ok())
|
||||
.unwrap_or_default();
|
||||
build_network_policy(&policy, &proxy_ports)
|
||||
} else {
|
||||
""
|
||||
String::new()
|
||||
};
|
||||
|
||||
let full_policy = format!(
|
||||
@@ -166,30 +305,67 @@ mod tests {
|
||||
use super::create_seatbelt_command_args;
|
||||
use super::macos_dir_params;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct CodexHomeGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexHomeGuard {
|
||||
fn new(path: &Path) -> Self {
|
||||
let previous = std::env::var("CODEX_HOME").ok();
|
||||
// SAFETY: these tests execute serially, and we restore the original value in Drop.
|
||||
unsafe {
|
||||
std::env::set_var("CODEX_HOME", path);
|
||||
}
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CodexHomeGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: these tests execute serially, and we restore the original value before other
|
||||
// tests run.
|
||||
unsafe {
|
||||
if let Some(previous) = self.previous.take() {
|
||||
std::env::set_var("CODEX_HOME", previous);
|
||||
} else {
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_seatbelt_args_with_read_only_git_subpath() {
|
||||
#[serial]
|
||||
fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
|
||||
// Create a temporary workspace with two writable roots: one containing
|
||||
// a top-level .git directory and one without it.
|
||||
// top-level .git and .codex directories and one without them.
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let _codex_home_guard = CodexHomeGuard::new(tmp.path());
|
||||
let PopulatedTmp {
|
||||
root_with_git,
|
||||
root_without_git,
|
||||
root_with_git_canon,
|
||||
root_with_git_git_canon,
|
||||
root_without_git_canon,
|
||||
vulnerable_root,
|
||||
vulnerable_root_canonical,
|
||||
dot_git_canonical,
|
||||
dot_codex_canonical,
|
||||
empty_root,
|
||||
empty_root_canonical,
|
||||
} = populate_tmpdir(tmp.path());
|
||||
let cwd = tmp.path().join("cwd");
|
||||
fs::create_dir_all(&cwd).expect("create cwd");
|
||||
let env = std::collections::HashMap::new();
|
||||
|
||||
// Build a policy that only includes the two test roots as writable and
|
||||
// does not automatically include defaults TMPDIR or /tmp.
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![root_with_git, root_without_git]
|
||||
writable_roots: vec![vulnerable_root, empty_root]
|
||||
.into_iter()
|
||||
.map(|p| p.try_into().unwrap())
|
||||
.collect(),
|
||||
@@ -198,23 +374,34 @@ mod tests {
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let args = create_seatbelt_command_args(
|
||||
vec!["/bin/echo".to_string(), "hello".to_string()],
|
||||
&policy,
|
||||
&cwd,
|
||||
);
|
||||
// Create the Seatbelt command to wrap a shell command that tries to
|
||||
// write to .codex/config.toml in the vulnerable root.
|
||||
let shell_command: Vec<String> = [
|
||||
"bash",
|
||||
"-c",
|
||||
"echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"",
|
||||
"bash",
|
||||
dot_codex_canonical
|
||||
.join("config.toml")
|
||||
.to_string_lossy()
|
||||
.as_ref(),
|
||||
]
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, &env);
|
||||
|
||||
// Build the expected policy text using a raw string for readability.
|
||||
// Note that the policy includes:
|
||||
// - the base policy,
|
||||
// - read-only access to the filesystem,
|
||||
// - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
|
||||
// - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2.
|
||||
let expected_policy = format!(
|
||||
r#"{MACOS_SEATBELT_BASE_POLICY}
|
||||
; allow read-only file operations
|
||||
(allow file-read*)
|
||||
(allow file-write*
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
|
||||
)
|
||||
"#,
|
||||
);
|
||||
@@ -224,17 +411,26 @@ mod tests {
|
||||
expected_policy,
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0={}",
|
||||
root_with_git_canon.to_string_lossy()
|
||||
vulnerable_root_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_RO_0={}",
|
||||
root_with_git_git_canon.to_string_lossy()
|
||||
dot_git_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_RO_1={}",
|
||||
dot_codex_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1={}",
|
||||
root_without_git_canon.to_string_lossy()
|
||||
empty_root_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_2={}",
|
||||
cwd.canonicalize()
|
||||
.expect("canonicalize cwd")
|
||||
.to_string_lossy()
|
||||
),
|
||||
format!("-DWRITABLE_ROOT_2={}", cwd.to_string_lossy()),
|
||||
];
|
||||
|
||||
expected_args.extend(
|
||||
@@ -243,30 +439,130 @@ mod tests {
|
||||
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
|
||||
);
|
||||
|
||||
expected_args.extend(vec![
|
||||
"--".to_string(),
|
||||
"/bin/echo".to_string(),
|
||||
"hello".to_string(),
|
||||
]);
|
||||
expected_args.push("--".to_string());
|
||||
expected_args.extend(shell_command);
|
||||
|
||||
assert_eq!(expected_args, args);
|
||||
|
||||
// Verify that .codex/config.toml cannot be modified under the generated
|
||||
// Seatbelt policy.
|
||||
let config_toml = dot_codex_canonical.join("config.toml");
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("execute seatbelt command");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
assert_eq!(
|
||||
"sandbox_mode = \"read-only\"\n",
|
||||
String::from_utf8_lossy(&fs::read(&config_toml).expect("read config.toml")),
|
||||
"config.toml should contain its original contents because it should not have been modified"
|
||||
);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"command to write {} should fail under seatbelt",
|
||||
&config_toml.display()
|
||||
);
|
||||
if stderr.starts_with("sandbox-exec: sandbox_apply:") {
|
||||
// Some environments (including Codex's own test harness) run the process under a
|
||||
// Seatbelt sandbox already, which prevents nested `sandbox-exec` usage. In that case,
|
||||
// we can still validate policy generation but cannot validate enforcement.
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
stderr,
|
||||
format!("bash: {}: Operation not permitted\n", config_toml.display()),
|
||||
);
|
||||
|
||||
// Create a similar Seatbelt command that tries to write to a file in
|
||||
// the .git folder, which should also be blocked.
|
||||
let pre_commit_hook = dot_git_canonical.join("hooks").join("pre-commit");
|
||||
let shell_command_git: Vec<String> = [
|
||||
"bash",
|
||||
"-c",
|
||||
"echo 'pwned!' > \"$1\"",
|
||||
"bash",
|
||||
pre_commit_hook.to_string_lossy().as_ref(),
|
||||
]
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let write_hooks_file_args =
|
||||
create_seatbelt_command_args(shell_command_git, &policy, &cwd, &env);
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&write_hooks_file_args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("execute seatbelt command");
|
||||
assert!(
|
||||
!fs::exists(&pre_commit_hook).expect("exists pre-commit hook"),
|
||||
"{} should not exist because it should not have been created",
|
||||
pre_commit_hook.display()
|
||||
);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"command to write {} should fail under seatbelt",
|
||||
&pre_commit_hook.display()
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
format!(
|
||||
"bash: {}: Operation not permitted\n",
|
||||
pre_commit_hook.display()
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that writing a file to the folder containing .git and .codex is allowed.
|
||||
let allowed_file = vulnerable_root_canonical.join("allowed.txt");
|
||||
let shell_command_allowed: Vec<String> = [
|
||||
"bash",
|
||||
"-c",
|
||||
"echo 'this is allowed' > \"$1\"",
|
||||
"bash",
|
||||
allowed_file.to_string_lossy().as_ref(),
|
||||
]
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let write_allowed_file_args =
|
||||
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, &env);
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&write_allowed_file_args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("execute seatbelt command");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"command to write {} should succeed under seatbelt",
|
||||
&allowed_file.display()
|
||||
);
|
||||
assert_eq!(
|
||||
"this is allowed\n",
|
||||
String::from_utf8_lossy(&fs::read(&allowed_file).expect("read allowed.txt")),
|
||||
"{} should contain the written text",
|
||||
allowed_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn create_seatbelt_args_for_cwd_as_git_repo() {
|
||||
// Create a temporary workspace with two writable roots: one containing
|
||||
// a top-level .git directory and one without it.
|
||||
// top-level .git and .codex directories and one without them.
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let _codex_home_guard = CodexHomeGuard::new(tmp.path());
|
||||
let PopulatedTmp {
|
||||
root_with_git,
|
||||
root_with_git_canon,
|
||||
root_with_git_git_canon,
|
||||
vulnerable_root,
|
||||
vulnerable_root_canonical,
|
||||
dot_git_canonical,
|
||||
dot_codex_canonical,
|
||||
..
|
||||
} = populate_tmpdir(tmp.path());
|
||||
let env = std::collections::HashMap::new();
|
||||
|
||||
// Build a policy that does not specify any writable_roots, but does
|
||||
// use the default ones (cwd and TMPDIR) and verifies the `.git` check
|
||||
// is done properly for cwd.
|
||||
// use the default ones (cwd and TMPDIR) and verifies the `.git` and
|
||||
// `.codex` checks are done properly for cwd.
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
@@ -274,10 +570,24 @@ mod tests {
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
|
||||
let shell_command: Vec<String> = [
|
||||
"bash",
|
||||
"-c",
|
||||
"echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"",
|
||||
"bash",
|
||||
dot_codex_canonical
|
||||
.join("config.toml")
|
||||
.to_string_lossy()
|
||||
.as_ref(),
|
||||
]
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let args = create_seatbelt_command_args(
|
||||
vec!["/bin/echo".to_string(), "hello".to_string()],
|
||||
shell_command.clone(),
|
||||
&policy,
|
||||
root_with_git.as_path(),
|
||||
vulnerable_root.as_path(),
|
||||
&env,
|
||||
);
|
||||
|
||||
let tmpdir_env_var = std::env::var("TMPDIR")
|
||||
@@ -296,13 +606,13 @@ mod tests {
|
||||
// Note that the policy includes:
|
||||
// - the base policy,
|
||||
// - read-only access to the filesystem,
|
||||
// - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
|
||||
// - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2.
|
||||
let expected_policy = format!(
|
||||
r#"{MACOS_SEATBELT_BASE_POLICY}
|
||||
; allow read-only file operations
|
||||
(allow file-read*)
|
||||
(allow file-write*
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
|
||||
)
|
||||
"#,
|
||||
);
|
||||
@@ -312,11 +622,15 @@ mod tests {
|
||||
expected_policy,
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0={}",
|
||||
root_with_git_canon.to_string_lossy()
|
||||
vulnerable_root_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_RO_0={}",
|
||||
root_with_git_git_canon.to_string_lossy()
|
||||
dot_git_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_0_RO_1={}",
|
||||
dot_codex_canonical.to_string_lossy()
|
||||
),
|
||||
format!(
|
||||
"-DWRITABLE_ROOT_1={}",
|
||||
@@ -337,42 +651,107 @@ mod tests {
|
||||
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
|
||||
);
|
||||
|
||||
expected_args.extend(vec![
|
||||
"--".to_string(),
|
||||
"/bin/echo".to_string(),
|
||||
"hello".to_string(),
|
||||
]);
|
||||
expected_args.push("--".to_string());
|
||||
expected_args.extend(shell_command);
|
||||
|
||||
assert_eq!(expected_args, args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn create_seatbelt_args_with_proxy_allowlist() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let _codex_home_guard = CodexHomeGuard::new(tmp.path());
|
||||
let policy = SandboxPolicy::DangerFullAccess;
|
||||
let cwd = std::env::current_dir().expect("getcwd");
|
||||
let env = std::collections::HashMap::from([(
|
||||
"HTTP_PROXY".to_string(),
|
||||
"http://127.0.0.1:3128".to_string(),
|
||||
)]);
|
||||
let args = create_seatbelt_command_args(vec!["true".to_string()], &policy, &cwd, &env);
|
||||
let policy_text = &args[1];
|
||||
assert!(
|
||||
policy_text.contains("(allow network-bind (local ip \"localhost:3128\"))"),
|
||||
"expected seatbelt policy to allow local proxy binding"
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("(allow network-inbound (local ip \"localhost:3128\"))"),
|
||||
"expected seatbelt policy to allow local proxy inbound"
|
||||
);
|
||||
assert!(
|
||||
policy_text.contains("(allow network-outbound (remote ip \"localhost:3128\"))"),
|
||||
"expected seatbelt policy to allow local proxy outbound"
|
||||
);
|
||||
assert!(
|
||||
!policy_text.contains("(remote tcp"),
|
||||
"`sandbox-exec` network addresses only support `localhost` or `*`, so we must not emit host allowlists"
|
||||
);
|
||||
assert!(
|
||||
!policy_text.contains("127.0.0.1:3128"),
|
||||
"seatbelt policy must not include numeric loopback hosts (it will fail to parse)"
|
||||
);
|
||||
assert!(
|
||||
!policy_text.contains("localhost:*"),
|
||||
"proxy-restricted policy should not allow all localhost ports"
|
||||
);
|
||||
}
|
||||
|
||||
struct PopulatedTmp {
|
||||
root_with_git: PathBuf,
|
||||
root_without_git: PathBuf,
|
||||
root_with_git_canon: PathBuf,
|
||||
root_with_git_git_canon: PathBuf,
|
||||
root_without_git_canon: PathBuf,
|
||||
/// Path containing a .git and .codex subfolder.
|
||||
/// For the purposes of this test, we consider this a "vulnerable" root
|
||||
/// because a bad actor could write to .git/hooks/pre-commit so an
|
||||
/// unsuspecting user would run code as privileged the next time they
|
||||
/// ran `git commit` themselves, or modified .codex/config.toml to
|
||||
/// contain `sandbox_mode = "danger-full-access"` so the agent would
|
||||
/// have full privileges the next time it ran in that repo.
|
||||
vulnerable_root: PathBuf,
|
||||
vulnerable_root_canonical: PathBuf,
|
||||
dot_git_canonical: PathBuf,
|
||||
dot_codex_canonical: PathBuf,
|
||||
|
||||
/// Path without .git or .codex subfolders.
|
||||
empty_root: PathBuf,
|
||||
/// Canonicalized version of `empty_root`.
|
||||
empty_root_canonical: PathBuf,
|
||||
}
|
||||
|
||||
fn populate_tmpdir(tmp: &Path) -> PopulatedTmp {
|
||||
let root_with_git = tmp.join("with_git");
|
||||
let root_without_git = tmp.join("no_git");
|
||||
fs::create_dir_all(&root_with_git).expect("create with_git");
|
||||
fs::create_dir_all(&root_without_git).expect("create no_git");
|
||||
fs::create_dir_all(root_with_git.join(".git")).expect("create .git");
|
||||
let vulnerable_root = tmp.join("vulnerable_root");
|
||||
fs::create_dir_all(&vulnerable_root).expect("create vulnerable_root");
|
||||
|
||||
// TODO(mbolin): Should also support the case where `.git` is a file
|
||||
// with a gitdir: ... line.
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.arg(".")
|
||||
.current_dir(&vulnerable_root)
|
||||
.output()
|
||||
.expect("git init .");
|
||||
|
||||
fs::create_dir_all(vulnerable_root.join(".codex")).expect("create .codex");
|
||||
fs::write(
|
||||
vulnerable_root.join(".codex").join("config.toml"),
|
||||
"sandbox_mode = \"read-only\"\n",
|
||||
)
|
||||
.expect("write .codex/config.toml");
|
||||
|
||||
let empty_root = tmp.join("empty_root");
|
||||
fs::create_dir_all(&empty_root).expect("create empty_root");
|
||||
|
||||
// Ensure we have canonical paths for -D parameter matching.
|
||||
let root_with_git_canon = root_with_git.canonicalize().expect("canonicalize with_git");
|
||||
let root_with_git_git_canon = root_with_git_canon.join(".git");
|
||||
let root_without_git_canon = root_without_git
|
||||
let vulnerable_root_canonical = vulnerable_root
|
||||
.canonicalize()
|
||||
.expect("canonicalize no_git");
|
||||
.expect("canonicalize vulnerable_root");
|
||||
let dot_git_canonical = vulnerable_root_canonical.join(".git");
|
||||
let dot_codex_canonical = vulnerable_root_canonical.join(".codex");
|
||||
let empty_root_canonical = empty_root.canonicalize().expect("canonicalize empty_root");
|
||||
PopulatedTmp {
|
||||
root_with_git,
|
||||
root_without_git,
|
||||
root_with_git_canon,
|
||||
root_with_git_git_canon,
|
||||
root_without_git_canon,
|
||||
vulnerable_root,
|
||||
vulnerable_root_canonical,
|
||||
dot_git_canonical,
|
||||
dot_codex_canonical,
|
||||
empty_root,
|
||||
empty_root_canonical,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl
|
||||
; network allow rules are injected by codex-core based on proxy settings.
|
||||
; Ref https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/network.sb;drc=f8f264d5e4e7509c913f4c60c2639d15905a07e4
|
||||
|
||||
(allow network-outbound)
|
||||
(allow network-inbound)
|
||||
(allow system-socket)
|
||||
|
||||
(allow mach-lookup
|
||||
|
||||
@@ -3,9 +3,11 @@ use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::skills::model::SkillError;
|
||||
use crate::skills::model::SkillLoadOutcome;
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use crate::skills::public::public_cache_root_dir;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
@@ -71,6 +73,11 @@ where
|
||||
discover_skills_under_root(&root.path, root.scope, &mut outcome);
|
||||
}
|
||||
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
outcome
|
||||
.skills
|
||||
.retain(|skill| seen.insert(skill.name.clone()));
|
||||
|
||||
outcome
|
||||
.skills
|
||||
.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
|
||||
@@ -85,22 +92,57 @@ pub(crate) fn user_skills_root(codex_home: &Path) -> SkillRoot {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn public_skills_root(codex_home: &Path) -> SkillRoot {
|
||||
SkillRoot {
|
||||
path: public_cache_root_dir(codex_home),
|
||||
scope: SkillScope::Public,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn repo_skills_root(cwd: &Path) -> Option<SkillRoot> {
|
||||
resolve_root_git_project_for_trust(cwd).map(|repo_root| SkillRoot {
|
||||
path: repo_root
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Repo,
|
||||
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
|
||||
let base = normalize_path(base).unwrap_or_else(|_| base.to_path_buf());
|
||||
|
||||
let repo_root =
|
||||
resolve_root_git_project_for_trust(&base).map(|root| normalize_path(&root).unwrap_or(root));
|
||||
|
||||
let scope = SkillScope::Repo;
|
||||
if let Some(repo_root) = repo_root.as_deref() {
|
||||
for dir in base.ancestors() {
|
||||
let skills_root = dir.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
if skills_root.is_dir() {
|
||||
return Some(SkillRoot {
|
||||
path: skills_root,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
if dir == repo_root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let skills_root = base.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
skills_root.is_dir().then_some(SkillRoot {
|
||||
path: skills_root,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn skill_roots(config: &Config) -> Vec<SkillRoot> {
|
||||
let mut roots = vec![user_skills_root(&config.codex_home)];
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Some(repo_root) = repo_skills_root(&config.cwd) {
|
||||
roots.push(repo_root);
|
||||
}
|
||||
|
||||
// Load order matters: we dedupe by name, keeping the first occurrence.
|
||||
// This makes repo/user skills win over public skills.
|
||||
roots.push(user_skills_root(&config.codex_home));
|
||||
roots.push(public_skills_root(&config.codex_home));
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
@@ -149,11 +191,17 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
|
||||
|
||||
if file_type.is_file() && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(&path, scope) {
|
||||
Ok(skill) => outcome.skills.push(skill),
|
||||
Err(err) => outcome.errors.push(SkillError {
|
||||
path,
|
||||
message: err.to_string(),
|
||||
}),
|
||||
Ok(skill) => {
|
||||
outcome.skills.push(skill);
|
||||
}
|
||||
Err(err) => {
|
||||
if scope != SkillScope::Public {
|
||||
outcome.errors.push(SkillError {
|
||||
path,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +281,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
@@ -251,11 +300,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf {
|
||||
write_skill_at(codex_home.path(), dir, name, description)
|
||||
write_skill_at(&codex_home.path().join("skills"), dir, name, description)
|
||||
}
|
||||
|
||||
fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf {
|
||||
let skill_dir = root.join(format!("skills/{dir}"));
|
||||
let skill_dir = root.join(dir);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let indented_description = description.replace('\n', "\n ");
|
||||
let content = format!(
|
||||
@@ -375,4 +424,316 @@ mod tests {
|
||||
assert_eq!(skill.name, "repo-skill");
|
||||
assert!(skill.path.starts_with(&repo_root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_skills_from_nearest_codex_dir_under_repo_root() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
let nested_dir = repo_dir.path().join("nested/inner");
|
||||
fs::create_dir_all(&nested_dir).unwrap();
|
||||
|
||||
write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"root",
|
||||
"root-skill",
|
||||
"from root",
|
||||
);
|
||||
write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join("nested")
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"nested",
|
||||
"nested-skill",
|
||||
"from nested",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = nested_dir;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "nested-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_skills_from_codex_dir_when_not_git_repo() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let work_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_skill_at(
|
||||
&work_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"local",
|
||||
"local-skill",
|
||||
"from cwd",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = work_dir.path().to_path_buf();
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "local-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_by_name_preferring_repo_over_user() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"repo",
|
||||
"dupe-skill",
|
||||
"from repo",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = repo_dir.path().to_path_buf();
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "dupe-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_skills_search_does_not_escape_repo_root() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let outer_dir = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = outer_dir.path().join("repo");
|
||||
fs::create_dir_all(&repo_dir).unwrap();
|
||||
|
||||
write_skill_at(
|
||||
&outer_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"outer",
|
||||
"outer-skill",
|
||||
"from outer",
|
||||
);
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(&repo_dir)
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = repo_dir;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_skills_when_cwd_is_file_in_repo() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"repo",
|
||||
"repo-skill",
|
||||
"from repo",
|
||||
);
|
||||
let file_path = repo_dir.path().join("some-file.txt");
|
||||
fs::write(&file_path, "contents").unwrap();
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = file_path;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "repo-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_repo_skills_search_does_not_walk_parents() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let outer_dir = tempfile::tempdir().expect("tempdir");
|
||||
let nested_dir = outer_dir.path().join("nested/inner");
|
||||
fs::create_dir_all(&nested_dir).unwrap();
|
||||
|
||||
write_skill_at(
|
||||
&outer_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"outer",
|
||||
"outer-skill",
|
||||
"from outer",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = nested_dir;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_skills_from_public_cache_when_present() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let work_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_skill_at(
|
||||
&codex_home.path().join("skills").join(".public"),
|
||||
"public",
|
||||
"public-skill",
|
||||
"from public",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = work_dir.path().to_path_buf();
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "public-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Public);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_by_name_preferring_user_over_public() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let work_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
write_skill_at(
|
||||
&codex_home.path().join("skills").join(".public"),
|
||||
"public",
|
||||
"dupe-skill",
|
||||
"from public",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = work_dir.path().to_path_buf();
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "dupe-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_by_name_preferring_repo_over_public() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"repo",
|
||||
"dupe-skill",
|
||||
"from repo",
|
||||
);
|
||||
write_skill_at(
|
||||
&codex_home.path().join("skills").join(".public"),
|
||||
"public",
|
||||
"dupe-skill",
|
||||
"from public",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home);
|
||||
cfg.cwd = repo_dir.path().to_path_buf();
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "dupe-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,82 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::skills::loader::public_skills_root;
|
||||
use crate::skills::loader::repo_skills_root;
|
||||
use crate::skills::loader::user_skills_root;
|
||||
use crate::skills::public::refresh_public_skills;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub struct SkillsManager {
|
||||
codex_home: PathBuf,
|
||||
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
|
||||
attempted_public_refresh: AtomicBool,
|
||||
skills_update_tx: broadcast::Sender<()>,
|
||||
}
|
||||
|
||||
impl SkillsManager {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
let (skills_update_tx, _skills_update_rx) = broadcast::channel(1);
|
||||
Self {
|
||||
codex_home,
|
||||
cache_by_cwd: RwLock::new(HashMap::new()),
|
||||
attempted_public_refresh: AtomicBool::new(false),
|
||||
skills_update_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_skills_update_notifications(&self) -> broadcast::Receiver<()> {
|
||||
self.skills_update_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
|
||||
self.skills_for_cwd_with_options(cwd, false)
|
||||
}
|
||||
|
||||
pub(crate) fn skills_for_cwd_with_options(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
force_reload: bool,
|
||||
) -> SkillLoadOutcome {
|
||||
// Best-effort refresh: attempt at most once per manager instance.
|
||||
if self
|
||||
.attempted_public_refresh
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let codex_home = self.codex_home.clone();
|
||||
let skills_update_tx = self.skills_update_tx.clone();
|
||||
std::thread::spawn(move || match refresh_public_skills(&codex_home) {
|
||||
Ok(outcome) => {
|
||||
if outcome.updated() {
|
||||
let _ = skills_update_tx.send(());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("failed to refresh public skills: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let cached = match self.cache_by_cwd.read() {
|
||||
Ok(cache) => cache.get(cwd).cloned(),
|
||||
Err(err) => err.into_inner().get(cwd).cloned(),
|
||||
};
|
||||
if let Some(outcome) = cached {
|
||||
if !force_reload && let Some(outcome) = cached {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let mut roots = vec![user_skills_root(&self.codex_home)];
|
||||
let mut roots = Vec::new();
|
||||
if let Some(repo_root) = repo_skills_root(cwd) {
|
||||
roots.push(repo_root);
|
||||
}
|
||||
roots.push(user_skills_root(&self.codex_home));
|
||||
roots.push(public_skills_root(&self.codex_home));
|
||||
let outcome = load_skills_from_roots(roots);
|
||||
match self.cache_by_cwd.write() {
|
||||
Ok(mut cache) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod injection;
|
||||
pub mod loader;
|
||||
pub mod manager;
|
||||
pub mod model;
|
||||
pub mod public;
|
||||
pub mod render;
|
||||
|
||||
pub(crate) use injection::SkillInjections;
|
||||
|
||||
397
codex-rs/core/src/skills/public.rs
Normal file
397
codex-rs/core/src/skills/public.rs
Normal file
@@ -0,0 +1,397 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitStatus;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
const PUBLIC_SKILLS_REPO_URL: &str = "https://github.com/openai/skills.git";
|
||||
const PUBLIC_SKILLS_DIR_NAME: &str = ".public";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
|
||||
struct TempDirCleanup {
|
||||
path: PathBuf,
|
||||
// Disable Drop cleanup after explicit cleanup to avoid double-delete.
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl TempDirCleanup {
|
||||
fn new(path: PathBuf) -> Self {
|
||||
Self { path, active: true }
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) -> Result<(), PublicSkillsError> {
|
||||
if self.active && self.path.exists() {
|
||||
fs::remove_dir_all(&self.path)
|
||||
.map_err(|source| PublicSkillsError::io("remove public skills tmp dir", source))?;
|
||||
}
|
||||
self.active = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDirCleanup {
|
||||
fn drop(&mut self) {
|
||||
if self.active && self.path.exists() {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn public_cache_root_dir(codex_home: &Path) -> PathBuf {
|
||||
codex_home
|
||||
.join(SKILLS_DIR_NAME)
|
||||
.join(PUBLIC_SKILLS_DIR_NAME)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PublicSkillsRefreshOutcome {
|
||||
Skipped,
|
||||
Updated,
|
||||
}
|
||||
|
||||
impl PublicSkillsRefreshOutcome {
|
||||
pub(crate) fn updated(self) -> bool {
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_public_skills(
|
||||
codex_home: &Path,
|
||||
) -> Result<PublicSkillsRefreshOutcome, PublicSkillsError> {
|
||||
// Keep tests deterministic and offline-safe. Tests that want to exercise the
|
||||
// refresh behavior should call `refresh_public_skills_from_repo_url`.
|
||||
if cfg!(test) {
|
||||
return Ok(PublicSkillsRefreshOutcome::Skipped);
|
||||
}
|
||||
refresh_public_skills_inner(codex_home, PUBLIC_SKILLS_REPO_URL)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn refresh_public_skills_from_repo_url(
|
||||
codex_home: &Path,
|
||||
repo_url: &str,
|
||||
) -> Result<PublicSkillsRefreshOutcome, PublicSkillsError> {
|
||||
refresh_public_skills_inner(codex_home, repo_url)
|
||||
}
|
||||
|
||||
fn refresh_public_skills_inner(
|
||||
codex_home: &Path,
|
||||
repo_url: &str,
|
||||
) -> Result<PublicSkillsRefreshOutcome, PublicSkillsError> {
|
||||
// Best-effort refresh: clone the repo to a temp dir, stage its `skills/`, then atomically swap
|
||||
// the staged directory into the public cache.
|
||||
let skills_root_dir = codex_home.join(SKILLS_DIR_NAME);
|
||||
fs::create_dir_all(&skills_root_dir)
|
||||
.map_err(|source| PublicSkillsError::io("create skills root dir", source))?;
|
||||
|
||||
let dest_public = public_cache_root_dir(codex_home);
|
||||
|
||||
let tmp_dir = skills_root_dir.join(format!(".public-tmp-{}", rand_suffix()));
|
||||
if tmp_dir.exists() {
|
||||
fs::remove_dir_all(&tmp_dir).map_err(|source| {
|
||||
PublicSkillsError::io("remove existing public skills tmp dir", source)
|
||||
})?;
|
||||
}
|
||||
fs::create_dir_all(&tmp_dir)
|
||||
.map_err(|source| PublicSkillsError::io("create public skills tmp dir", source))?;
|
||||
let mut tmp_dir_cleanup = TempDirCleanup::new(tmp_dir.clone());
|
||||
|
||||
let checkout_dir = tmp_dir.join("checkout");
|
||||
clone_repo(repo_url, &checkout_dir)?;
|
||||
|
||||
let src_skills = checkout_dir.join(SKILLS_DIR_NAME);
|
||||
let src_skills_metadata = fs::symlink_metadata(&src_skills)
|
||||
.map_err(|source| PublicSkillsError::io("read skills dir metadata", source))?;
|
||||
let src_skills_type = src_skills_metadata.file_type();
|
||||
if src_skills_type.is_symlink() || !src_skills_type.is_dir() {
|
||||
return Err(PublicSkillsError::RepoMissingSkillsDir {
|
||||
skills_dir_name: SKILLS_DIR_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
let staged_public = tmp_dir.join(PUBLIC_SKILLS_DIR_NAME);
|
||||
stage_skills_dir(&src_skills, &staged_public)?;
|
||||
|
||||
atomic_swap_dir(&staged_public, &dest_public, &skills_root_dir)?;
|
||||
|
||||
tmp_dir_cleanup.cleanup()?;
|
||||
Ok(PublicSkillsRefreshOutcome::Updated)
|
||||
}
|
||||
|
||||
fn stage_skills_dir(src: &Path, staged: &Path) -> Result<(), PublicSkillsError> {
|
||||
fs::rename(src, staged).map_err(|source| PublicSkillsError::io("stage skills dir", source))?;
|
||||
|
||||
prune_symlinks_and_special_files(staged)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prune_symlinks_and_special_files(root: &Path) -> Result<(), PublicSkillsError> {
|
||||
let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in fs::read_dir(&dir)
|
||||
.map_err(|source| PublicSkillsError::io("read staged skills dir", source))?
|
||||
{
|
||||
let entry = entry
|
||||
.map_err(|source| PublicSkillsError::io("read staged skills dir entry", source))?;
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|source| PublicSkillsError::io("read staged skills entry type", source))?;
|
||||
let path = entry.path();
|
||||
|
||||
if file_type.is_symlink() {
|
||||
fs::remove_file(&path).map_err(|source| {
|
||||
PublicSkillsError::io("remove symlink from staged skills", source)
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::remove_file(&path).map_err(|source| {
|
||||
PublicSkillsError::io("remove special file from staged skills", source)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clone_repo(repo_url: &str, checkout_dir: &Path) -> Result<(), PublicSkillsError> {
|
||||
let out = std::process::Command::new("git")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_ASKPASS", "true")
|
||||
.arg("clone")
|
||||
.arg("--depth")
|
||||
.arg("1")
|
||||
.arg(repo_url)
|
||||
.arg(checkout_dir)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|source| PublicSkillsError::io("spawn `git clone`", source))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stderr = stderr.trim();
|
||||
return if stderr.is_empty() {
|
||||
Err(PublicSkillsError::GitCloneFailed { status: out.status })
|
||||
} else {
|
||||
Err(PublicSkillsError::GitCloneFailedWithStderr {
|
||||
status: out.status,
|
||||
stderr: stderr.to_owned(),
|
||||
})
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn atomic_swap_dir(staged: &Path, dest: &Path, parent: &Path) -> Result<(), PublicSkillsError> {
|
||||
if let Some(dest_parent) = dest.parent() {
|
||||
fs::create_dir_all(dest_parent)
|
||||
.map_err(|source| PublicSkillsError::io("create public skills dest parent", source))?;
|
||||
}
|
||||
|
||||
let backup_base = dest
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("skills");
|
||||
let backup = parent.join(format!("{backup_base}.old-{}", rand_suffix()));
|
||||
if backup.exists() {
|
||||
fs::remove_dir_all(&backup)
|
||||
.map_err(|source| PublicSkillsError::io("remove old public skills backup", source))?;
|
||||
}
|
||||
|
||||
if dest.exists() {
|
||||
fs::rename(dest, &backup)
|
||||
.map_err(|source| PublicSkillsError::io("rename public skills to backup", source))?;
|
||||
}
|
||||
|
||||
if let Err(err) = fs::rename(staged, dest) {
|
||||
if backup.exists() {
|
||||
let _ = fs::rename(&backup, dest);
|
||||
}
|
||||
return Err(PublicSkillsError::io(
|
||||
"rename staged public skills into place",
|
||||
err,
|
||||
));
|
||||
}
|
||||
|
||||
if backup.exists() {
|
||||
fs::remove_dir_all(&backup)
|
||||
.map_err(|source| PublicSkillsError::io("remove public skills backup", source))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rand_suffix() -> String {
|
||||
let pid = std::process::id();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("{pid:x}-{nanos:x}")
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum PublicSkillsError {
|
||||
#[error("io error while {action}: {source}")]
|
||||
Io {
|
||||
action: &'static str,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("repo did not contain a `{skills_dir_name}` directory")]
|
||||
RepoMissingSkillsDir { skills_dir_name: &'static str },
|
||||
|
||||
#[error("`git clone` failed with status {status}")]
|
||||
GitCloneFailed { status: ExitStatus },
|
||||
|
||||
#[error("`git clone` failed with status {status}: {stderr}")]
|
||||
GitCloneFailedWithStderr { status: ExitStatus, stderr: String },
|
||||
}
|
||||
|
||||
impl PublicSkillsError {
|
||||
fn io(action: &'static str, source: std::io::Error) -> Self {
|
||||
Self::Io { action, source }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_public_skill(repo_dir: &TempDir, name: &str, description: &str) {
|
||||
let skills_dir = repo_dir.path().join("skills").join(name);
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
fs::write(skills_dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn git(repo_dir: &TempDir, args: &[&str]) {
|
||||
let status = std::process::Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=codex-test",
|
||||
"-c",
|
||||
"user.email=codex-test@example.com",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "git command failed: {args:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_copies_skills_subdir_into_public_cache() {
|
||||
let codex_home = tempfile::tempdir().unwrap();
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git(&repo_dir, &["init"]);
|
||||
write_public_skill(&repo_dir, "demo", "from repo");
|
||||
git(&repo_dir, &["add", "."]);
|
||||
git(&repo_dir, &["commit", "-m", "init"]);
|
||||
|
||||
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let path = public_cache_root_dir(codex_home.path())
|
||||
.join("demo")
|
||||
.join("SKILL.md");
|
||||
let contents = fs::read_to_string(path).unwrap();
|
||||
assert!(contents.contains("name: demo"));
|
||||
assert!(contents.contains("description: from repo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_overwrites_existing_public_cache() {
|
||||
let codex_home = tempfile::tempdir().unwrap();
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git(&repo_dir, &["init"]);
|
||||
write_public_skill(&repo_dir, "demo", "v1");
|
||||
git(&repo_dir, &["add", "."]);
|
||||
git(&repo_dir, &["commit", "-m", "v1"]);
|
||||
|
||||
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
write_public_skill(&repo_dir, "demo", "v2");
|
||||
git(&repo_dir, &["add", "."]);
|
||||
git(&repo_dir, &["commit", "-m", "v2"]);
|
||||
|
||||
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let path = public_cache_root_dir(codex_home.path())
|
||||
.join("demo")
|
||||
.join("SKILL.md");
|
||||
let contents = fs::read_to_string(path).unwrap();
|
||||
assert_eq!(contents.matches("description:").count(), 1);
|
||||
assert!(contents.contains("description: v2"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn refresh_prunes_symlinks_inside_skills_dir() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let codex_home = tempfile::tempdir().unwrap();
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git(&repo_dir, &["init"]);
|
||||
write_public_skill(&repo_dir, "demo", "from repo");
|
||||
|
||||
let demo_dir = repo_dir.path().join("skills").join("demo");
|
||||
symlink("SKILL.md", demo_dir.join("link-to-skill")).unwrap();
|
||||
git(&repo_dir, &["add", "."]);
|
||||
git(&repo_dir, &["commit", "-m", "init"]);
|
||||
|
||||
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!public_cache_root_dir(codex_home.path())
|
||||
.join("demo")
|
||||
.join("link-to-skill")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn refresh_rejects_symlinked_skills_dir() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let codex_home = tempfile::tempdir().unwrap();
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git(&repo_dir, &["init"]);
|
||||
|
||||
let skills_target = repo_dir.path().join("skills-target");
|
||||
fs::create_dir_all(skills_target.join("demo")).unwrap();
|
||||
fs::write(
|
||||
skills_target.join("demo").join("SKILL.md"),
|
||||
"---\nname: demo\ndescription: from repo\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
symlink("skills-target", repo_dir.path().join("skills")).unwrap();
|
||||
git(&repo_dir, &["add", "."]);
|
||||
git(&repo_dir, &["commit", "-m", "init"]);
|
||||
|
||||
let err = refresh_public_skills_from_repo_url(
|
||||
codex_home.path(),
|
||||
repo_dir.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("repo did not contain"));
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,13 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Skills".to_string());
|
||||
lines.push("These skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.".to_string());
|
||||
lines.push("These skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.".to_string());
|
||||
|
||||
for skill in skills {
|
||||
let path_str = skill.path.to_string_lossy().replace('\\', "/");
|
||||
lines.push(format!(
|
||||
"- {}: {} (file: {})",
|
||||
skill.name, skill.description, path_str
|
||||
));
|
||||
let name = skill.name.as_str();
|
||||
let description = skill.description.as_str();
|
||||
lines.push(format!("- {name}: {description} (file: {path_str})"));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
|
||||
@@ -39,7 +39,7 @@ pub(crate) struct HandleOutputCtx {
|
||||
pub cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn handle_output_item_done(
|
||||
ctx: &mut HandleOutputCtx,
|
||||
item: ResponseItem,
|
||||
|
||||
@@ -41,31 +41,36 @@ impl SessionTask for GhostSnapshotTask {
|
||||
) -> Option<String> {
|
||||
tokio::task::spawn(async move {
|
||||
let token = self.token;
|
||||
let warnings_enabled = !ctx.ghost_snapshot.disable_warnings;
|
||||
// Channel used to signal when the snapshot work has finished so the
|
||||
// timeout warning task can exit early without sending a warning.
|
||||
let (snapshot_done_tx, snapshot_done_rx) = oneshot::channel::<()>();
|
||||
let ctx_for_warning = ctx.clone();
|
||||
let cancellation_token_for_warning = cancellation_token.clone();
|
||||
let session_for_warning = session.clone();
|
||||
// Fire a generic warning if the snapshot is still running after
|
||||
// three minutes; this helps users discover large untracked files
|
||||
// that might need to be added to .gitignore.
|
||||
tokio::task::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => {
|
||||
session_for_warning.session
|
||||
.send_event(
|
||||
&ctx_for_warning,
|
||||
EventMsg::Warning(WarningEvent {
|
||||
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if warnings_enabled {
|
||||
let ctx_for_warning = ctx.clone();
|
||||
let cancellation_token_for_warning = cancellation_token.clone();
|
||||
let session_for_warning = session.clone();
|
||||
// Fire a generic warning if the snapshot is still running after
|
||||
// three minutes; this helps users discover large untracked files
|
||||
// that might need to be added to .gitignore.
|
||||
tokio::task::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => {
|
||||
session_for_warning.session
|
||||
.send_event(
|
||||
&ctx_for_warning,
|
||||
EventMsg::Warning(WarningEvent {
|
||||
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ = snapshot_done_rx => {}
|
||||
_ = cancellation_token_for_warning.cancelled() => {}
|
||||
}
|
||||
_ = snapshot_done_rx => {}
|
||||
_ = cancellation_token_for_warning.cancelled() => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
drop(snapshot_done_rx);
|
||||
}
|
||||
|
||||
let ctx_for_task = ctx.clone();
|
||||
let cancelled = tokio::select! {
|
||||
@@ -84,18 +89,20 @@ impl SessionTask for GhostSnapshotTask {
|
||||
{
|
||||
Ok(Ok((ghost_commit, report))) => {
|
||||
info!("ghost snapshot blocking task finished");
|
||||
for message in format_snapshot_warnings(
|
||||
ghost_snapshot.ignore_large_untracked_files,
|
||||
ghost_snapshot.ignore_large_untracked_dirs,
|
||||
&report,
|
||||
) {
|
||||
session
|
||||
.session
|
||||
.send_event(
|
||||
&ctx_for_task,
|
||||
EventMsg::Warning(WarningEvent { message }),
|
||||
)
|
||||
.await;
|
||||
if warnings_enabled {
|
||||
for message in format_snapshot_warnings(
|
||||
ghost_snapshot.ignore_large_untracked_files,
|
||||
ghost_snapshot.ignore_large_untracked_dirs,
|
||||
&report,
|
||||
) {
|
||||
session
|
||||
.session
|
||||
.send_event(
|
||||
&ctx_for_task,
|
||||
EventMsg::Warning(WarningEvent { message }),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
session
|
||||
.session
|
||||
|
||||
@@ -159,6 +159,7 @@ impl Session {
|
||||
for task in self.take_all_running_tasks().await {
|
||||
self.handle_task_abort(task, reason.clone()).await;
|
||||
}
|
||||
self.close_unified_exec_sessions().await;
|
||||
}
|
||||
|
||||
pub async fn on_task_finished(
|
||||
@@ -167,12 +168,18 @@ impl Session {
|
||||
last_agent_message: Option<String>,
|
||||
) {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
if let Some(at) = active.as_mut()
|
||||
let should_close_sessions = if let Some(at) = active.as_mut()
|
||||
&& at.remove_task(&turn_context.sub_id)
|
||||
{
|
||||
*active = None;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
drop(active);
|
||||
if should_close_sessions {
|
||||
self.close_unified_exec_sessions().await;
|
||||
}
|
||||
let event = EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message });
|
||||
self.send_event(turn_context.as_ref(), event).await;
|
||||
}
|
||||
@@ -196,6 +203,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_unified_exec_sessions(&self) {
|
||||
self.services
|
||||
.unified_exec_manager
|
||||
.terminate_all_sessions()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn handle_task_abort(self: &Arc<Self>, task: RunningTask, reason: TurnAbortReason) {
|
||||
let sub_id = task.turn_context.sub_id.clone();
|
||||
if task.cancellation_token.is_cancelled() {
|
||||
|
||||
@@ -7,7 +7,7 @@ use async_trait::async_trait;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::Instrument;
|
||||
use tracing::info_span;
|
||||
use tracing::trace_span;
|
||||
|
||||
use super::SessionTask;
|
||||
use super::SessionTaskContext;
|
||||
@@ -30,7 +30,7 @@ impl SessionTask for RegularTask {
|
||||
) -> Option<String> {
|
||||
let sess = session.clone_session();
|
||||
let run_task_span =
|
||||
info_span!(parent: sess.services.otel_manager.current_span(), "run_task");
|
||||
trace_span!(parent: sess.services.otel_manager.current_span(), "run_task");
|
||||
run_task(sess, ctx, input, cancellation_token)
|
||||
.instrument(run_task_span)
|
||||
.await
|
||||
|
||||
@@ -16,7 +16,6 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex_delegate::run_codex_conversation_one_shot;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::review_format::format_review_findings_block;
|
||||
use crate::review_format::render_review_output_text;
|
||||
use crate::state::TaskKind;
|
||||
@@ -78,7 +77,6 @@ async fn start_review_conversation(
|
||||
) -> Option<async_channel::Receiver<Event>> {
|
||||
let config = ctx.client.config();
|
||||
let mut sub_agent_config = config.as_ref().clone();
|
||||
sub_agent_config.sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
// Run with only reviewer rubric — drop outer user_instructions
|
||||
sub_agent_config.user_instructions = None;
|
||||
// Avoid loading project docs; reviewer only needs findings
|
||||
|
||||
@@ -21,7 +21,6 @@ use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandBeginEvent;
|
||||
use crate::protocol::ExecCommandEndEvent;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::TaskStartedEvent;
|
||||
use crate::sandboxing::ExecEnv;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
@@ -96,9 +95,11 @@ impl SessionTask for UserShellCommandTask {
|
||||
let exec_env = ExecEnv {
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
env: create_env(&turn_context.shell_environment_policy),
|
||||
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
|
||||
// should use that instead of an "arbitrarily large" timeout here.
|
||||
env: create_env(
|
||||
&turn_context.shell_environment_policy,
|
||||
&turn_context.sandbox_policy,
|
||||
&turn_context.network_proxy,
|
||||
),
|
||||
expiration: USER_SHELL_TIMEOUT_MS.into(),
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -112,8 +113,7 @@ impl SessionTask for UserShellCommandTask {
|
||||
tx_event: session.get_tx_event(),
|
||||
});
|
||||
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
|
||||
let exec_result = execute_exec_env(exec_env, &turn_context.sandbox_policy, stdout_stream)
|
||||
.or_cancel(&cancellation_token)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -37,9 +37,6 @@ pub enum ToolPayload {
|
||||
LocalShell {
|
||||
params: ShellToolCallParams,
|
||||
},
|
||||
UnifiedExec {
|
||||
arguments: String,
|
||||
},
|
||||
Mcp {
|
||||
server: String,
|
||||
tool: String,
|
||||
@@ -53,7 +50,6 @@ impl ToolPayload {
|
||||
ToolPayload::Function { arguments } => Cow::Borrowed(arguments),
|
||||
ToolPayload::Custom { input } => Cow::Borrowed(input),
|
||||
ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")),
|
||||
ToolPayload::UnifiedExec { arguments } => Cow::Borrowed(arguments),
|
||||
ToolPayload::Mcp { raw_arguments, .. } => Cow::Borrowed(raw_arguments),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::exec_env::create_env;
|
||||
use crate::exec_policy::create_exec_approval_requirement_for_command;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::network_proxy;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::shell::Shell;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
@@ -22,6 +23,7 @@ use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
|
||||
pub struct ShellHandler;
|
||||
@@ -34,8 +36,12 @@ impl ShellHandler {
|
||||
command: params.command,
|
||||
cwd: turn_context.resolve_path(params.workdir.clone()),
|
||||
expiration: params.timeout_ms.into(),
|
||||
env: create_env(&turn_context.shell_environment_policy),
|
||||
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
|
||||
env: create_env(
|
||||
&turn_context.shell_environment_policy,
|
||||
&turn_context.sandbox_policy,
|
||||
&turn_context.network_proxy,
|
||||
),
|
||||
justification: params.justification,
|
||||
arg0: None,
|
||||
}
|
||||
@@ -60,8 +66,12 @@ impl ShellCommandHandler {
|
||||
command,
|
||||
cwd: turn_context.resolve_path(params.workdir.clone()),
|
||||
expiration: params.timeout_ms.into(),
|
||||
env: create_env(&turn_context.shell_environment_policy),
|
||||
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
|
||||
env: create_env(
|
||||
&turn_context.shell_environment_policy,
|
||||
&turn_context.sandbox_policy,
|
||||
&turn_context.network_proxy,
|
||||
),
|
||||
justification: params.justification,
|
||||
arg0: None,
|
||||
}
|
||||
@@ -252,7 +262,7 @@ impl ShellHandler {
|
||||
emitter.begin(event_ctx).await;
|
||||
|
||||
let features = session.features();
|
||||
let exec_approval_requirement = create_exec_approval_requirement_for_command(
|
||||
let mut exec_approval_requirement = create_exec_approval_requirement_for_command(
|
||||
&turn.exec_policy,
|
||||
&features,
|
||||
&exec_params.command,
|
||||
@@ -261,6 +271,31 @@ impl ShellHandler {
|
||||
exec_params.sandbox_permissions,
|
||||
)
|
||||
.await;
|
||||
let network_preflight_blocked = match network_proxy::preflight_blocked_host_if_enabled(
|
||||
&turn.network_proxy,
|
||||
&turn.sandbox_policy,
|
||||
&exec_params.command,
|
||||
) {
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "network proxy preflight failed");
|
||||
false
|
||||
}
|
||||
};
|
||||
let mut network_preflight_only = false;
|
||||
if network_preflight_blocked
|
||||
&& matches!(
|
||||
exec_approval_requirement,
|
||||
ExecApprovalRequirement::Skip { .. }
|
||||
)
|
||||
{
|
||||
exec_approval_requirement = ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("Network access requires approval.".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
};
|
||||
network_preflight_only = true;
|
||||
}
|
||||
|
||||
let req = ShellRequest {
|
||||
command: exec_params.command.clone(),
|
||||
@@ -269,6 +304,7 @@ impl ShellHandler {
|
||||
env: exec_params.env.clone(),
|
||||
sandbox_permissions: exec_params.sandbox_permissions,
|
||||
justification: exec_params.justification.clone(),
|
||||
network_preflight_only,
|
||||
exec_approval_requirement,
|
||||
};
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
@@ -371,7 +407,11 @@ mod tests {
|
||||
|
||||
let expected_command = session.user_shell().derive_exec_args(&command, true);
|
||||
let expected_cwd = turn_context.resolve_path(workdir.clone());
|
||||
let expected_env = create_env(&turn_context.shell_environment_policy);
|
||||
let expected_env = create_env(
|
||||
&turn_context.shell_environment_policy,
|
||||
&turn_context.sandbox_policy,
|
||||
&turn_context.network_proxy,
|
||||
);
|
||||
|
||||
let params = ShellCommandToolCallParams {
|
||||
command,
|
||||
|
||||
@@ -77,16 +77,15 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(
|
||||
payload,
|
||||
ToolPayload::Function { .. } | ToolPayload::UnifiedExec { .. }
|
||||
)
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
|
||||
let (ToolPayload::Function { arguments } | ToolPayload::UnifiedExec { arguments }) =
|
||||
&invocation.payload
|
||||
else {
|
||||
let ToolPayload::Function { arguments } = &invocation.payload else {
|
||||
tracing::error!(
|
||||
"This should never happen, invocation payload is wrong: {:?}",
|
||||
invocation.payload
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -110,7 +109,6 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
ToolPayload::UnifiedExec { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"unified_exec handler received unsupported payload".to_string(),
|
||||
|
||||
@@ -70,9 +70,11 @@ pub fn format_exec_output_for_model_freeform(
|
||||
// round to 1 decimal place
|
||||
let duration_seconds = ((exec_output.duration.as_secs_f32()) * 10.0).round() / 10.0;
|
||||
|
||||
let total_lines = exec_output.aggregated_output.text.lines().count();
|
||||
let content = build_content_with_timeout(exec_output);
|
||||
|
||||
let formatted_output = truncate_text(&exec_output.aggregated_output.text, truncation_policy);
|
||||
let total_lines = content.lines().count();
|
||||
|
||||
let formatted_output = truncate_text(&content, truncation_policy);
|
||||
|
||||
let mut sections = Vec::new();
|
||||
|
||||
@@ -92,21 +94,21 @@ pub fn format_exec_output_str(
|
||||
exec_output: &ExecToolCallOutput,
|
||||
truncation_policy: TruncationPolicy,
|
||||
) -> String {
|
||||
let ExecToolCallOutput {
|
||||
aggregated_output, ..
|
||||
} = exec_output;
|
||||
|
||||
let content = aggregated_output.text.as_str();
|
||||
|
||||
let body = if exec_output.timed_out {
|
||||
format!(
|
||||
"command timed out after {} milliseconds\n{content}",
|
||||
exec_output.duration.as_millis()
|
||||
)
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
let content = build_content_with_timeout(exec_output);
|
||||
|
||||
// Truncate for model consumption before serialization.
|
||||
formatted_truncate_text(&body, truncation_policy)
|
||||
formatted_truncate_text(&content, truncation_policy)
|
||||
}
|
||||
|
||||
/// Extracts exec output content and prepends a timeout message if the command timed out.
|
||||
fn build_content_with_timeout(exec_output: &ExecToolCallOutput) -> String {
|
||||
if exec_output.timed_out {
|
||||
format!(
|
||||
"command timed out after {} milliseconds\n{}",
|
||||
exec_output.duration.as_millis(),
|
||||
exec_output.aggregated_output.text
|
||||
)
|
||||
} else {
|
||||
exec_output.aggregated_output.text.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ use tokio_util::either::Either;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tracing::Instrument;
|
||||
use tracing::info_span;
|
||||
use tracing::instrument;
|
||||
use tracing::trace_span;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
@@ -45,7 +45,7 @@ impl ToolCallRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, fields(call = ?call))]
|
||||
#[instrument(level = "trace", skip_all, fields(call = ?call))]
|
||||
pub(crate) fn handle_tool_call(
|
||||
self,
|
||||
call: ToolCall,
|
||||
@@ -60,7 +60,7 @@ impl ToolCallRuntime {
|
||||
let lock = Arc::clone(&self.parallel_execution);
|
||||
let started = Instant::now();
|
||||
|
||||
let dispatch_span = info_span!(
|
||||
let dispatch_span = trace_span!(
|
||||
"dispatch_tool_call",
|
||||
otel.name = call.tool_name.as_str(),
|
||||
tool_name = call.tool_name.as_str(),
|
||||
|
||||
@@ -55,7 +55,7 @@ impl ToolRouter {
|
||||
.any(|config| config.spec.name() == tool_name)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, err)]
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
pub async fn build_tool_call(
|
||||
session: &Session,
|
||||
item: ResponseItem,
|
||||
@@ -78,15 +78,10 @@ impl ToolRouter {
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
let payload = if name == "unified_exec" {
|
||||
ToolPayload::UnifiedExec { arguments }
|
||||
} else {
|
||||
ToolPayload::Function { arguments }
|
||||
};
|
||||
Ok(Some(ToolCall {
|
||||
tool_name: name,
|
||||
call_id,
|
||||
payload,
|
||||
payload: ToolPayload::Function { arguments },
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -131,7 +126,7 @@ impl ToolRouter {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, err)]
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
pub async fn dispatch_tool_call(
|
||||
&self,
|
||||
session: Arc<Session>,
|
||||
|
||||
@@ -119,6 +119,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
|
||||
cwd,
|
||||
Some(reason),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
} else if user_explicitly_approved {
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct ShellRequest {
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub justification: Option<String>,
|
||||
pub network_preflight_only: bool,
|
||||
pub exec_approval_requirement: ExecApprovalRequirement,
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
req.network_preflight_only,
|
||||
)
|
||||
.await
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ pub struct UnifiedExecRequest {
|
||||
pub env: HashMap<String, String>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub justification: Option<String>,
|
||||
pub network_preflight_only: bool,
|
||||
pub exec_approval_requirement: ExecApprovalRequirement,
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ impl UnifiedExecRequest {
|
||||
env: HashMap<String, String>,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
justification: Option<String>,
|
||||
network_preflight_only: bool,
|
||||
exec_approval_requirement: ExecApprovalRequirement,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -65,6 +67,7 @@ impl UnifiedExecRequest {
|
||||
env,
|
||||
sandbox_permissions,
|
||||
justification,
|
||||
network_preflight_only,
|
||||
exec_approval_requirement,
|
||||
}
|
||||
}
|
||||
@@ -124,6 +127,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
req.network_preflight_only,
|
||||
)
|
||||
.await
|
||||
})
|
||||
|
||||
@@ -43,7 +43,12 @@ impl ToolsConfig {
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
ConfigShellToolType::Disabled
|
||||
} else if features.enabled(Feature::UnifiedExec) {
|
||||
ConfigShellToolType::UnifiedExec
|
||||
// If ConPTY not supported (for old Windows versions), fallback on ShellCommand.
|
||||
if codex_utils_pty::conpty_supported() {
|
||||
ConfigShellToolType::UnifiedExec
|
||||
} else {
|
||||
ConfigShellToolType::ShellCommand
|
||||
}
|
||||
} else {
|
||||
model_family.shell_type
|
||||
};
|
||||
@@ -153,8 +158,7 @@ fn create_exec_command_tool() -> ToolSpec {
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with -l/-i semantics. Defaults to false unless a shell snapshot is available."
|
||||
.to_string(),
|
||||
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -336,7 +340,7 @@ fn create_shell_command_tool() -> ToolSpec {
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with login shell semantics. Defaults to false unless a shell snapshot is available."
|
||||
"Whether to run the shell with login shell semantics. Defaults to true."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::exec_policy::create_exec_approval_requirement_for_command;
|
||||
use crate::network_proxy;
|
||||
use crate::protocol::BackgroundEventEvent;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::sandboxing::ExecEnv;
|
||||
@@ -175,7 +176,6 @@ impl UnifiedExecSessionManager {
|
||||
// Short‑lived command: emit ExecCommandEnd immediately using the
|
||||
// same helper as the background watcher, so all end events share
|
||||
// one implementation.
|
||||
self.release_process_id(&request.process_id).await;
|
||||
let exit = exit_code.unwrap_or(-1);
|
||||
emit_exec_end_for_unified_exec(
|
||||
Arc::clone(&context.session),
|
||||
@@ -191,6 +191,7 @@ impl UnifiedExecSessionManager {
|
||||
)
|
||||
.await;
|
||||
|
||||
self.release_process_id(&request.process_id).await;
|
||||
session.check_for_sandbox_denial_with_text(&text).await?;
|
||||
} else {
|
||||
// Long‑lived command: persist the session so write_stdin can reuse
|
||||
@@ -480,11 +481,27 @@ impl UnifiedExecSessionManager {
|
||||
justification: Option<String>,
|
||||
context: &UnifiedExecContext,
|
||||
) -> Result<UnifiedExecSession, UnifiedExecError> {
|
||||
let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy));
|
||||
let env = apply_unified_exec_env(create_env(
|
||||
&context.turn.shell_environment_policy,
|
||||
&context.turn.sandbox_policy,
|
||||
&context.turn.network_proxy,
|
||||
));
|
||||
let features = context.session.features();
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = UnifiedExecRuntime::new(self);
|
||||
let exec_approval_requirement = create_exec_approval_requirement_for_command(
|
||||
let network_preflight_blocked = match network_proxy::preflight_blocked_host_if_enabled(
|
||||
&context.turn.network_proxy,
|
||||
&context.turn.sandbox_policy,
|
||||
command,
|
||||
) {
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "network proxy preflight failed");
|
||||
false
|
||||
}
|
||||
};
|
||||
let mut exec_approval_requirement = create_exec_approval_requirement_for_command(
|
||||
&context.turn.exec_policy,
|
||||
&features,
|
||||
command,
|
||||
@@ -493,12 +510,27 @@ impl UnifiedExecSessionManager {
|
||||
sandbox_permissions,
|
||||
)
|
||||
.await;
|
||||
let mut network_preflight_only = false;
|
||||
if network_preflight_blocked
|
||||
&& matches!(
|
||||
exec_approval_requirement,
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Skip { .. }
|
||||
)
|
||||
{
|
||||
exec_approval_requirement =
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("Network access requires approval.".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
};
|
||||
network_preflight_only = true;
|
||||
}
|
||||
let req = UnifiedExecToolRequest::new(
|
||||
command.to_vec(),
|
||||
cwd,
|
||||
env,
|
||||
sandbox_permissions,
|
||||
justification,
|
||||
network_preflight_only,
|
||||
exec_approval_requirement,
|
||||
);
|
||||
let tool_ctx = ToolCtx {
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::path::PathBuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
use assert_cmd::cargo::cargo_bin;
|
||||
|
||||
pub mod process;
|
||||
pub mod responses;
|
||||
pub mod streaming_sse;
|
||||
pub mod test_codex;
|
||||
|
||||
48
codex-rs/core/tests/common/process.rs
Normal file
48
codex-rs/core/tests/common/process.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use anyhow::Context;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn wait_for_pid_file(path: &Path) -> anyhow::Result<String> {
|
||||
let pid = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Ok(contents) = fs::read_to_string(path) {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("timed out waiting for pid file")?;
|
||||
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
pub fn process_is_alive(pid: &str) -> anyhow::Result<bool> {
|
||||
let status = std::process::Command::new("kill")
|
||||
.args(["-0", pid])
|
||||
.status()
|
||||
.context("failed to probe process liveness with kill -0")?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
async fn wait_for_process_exit_inner(pid: String) -> anyhow::Result<()> {
|
||||
loop {
|
||||
if !process_is_alive(&pid)? {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_process_exit(pid: &str) -> anyhow::Result<()> {
|
||||
let pid = pid.to_string();
|
||||
tokio::time::timeout(Duration::from_secs(2), wait_for_process_exit_inner(pid))
|
||||
.await
|
||||
.context("timed out waiting for process to exit")??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
@@ -126,7 +127,7 @@ impl ActionKind {
|
||||
);
|
||||
|
||||
let command = format!("python3 -c \"{script}\"");
|
||||
let event = shell_event(call_id, &command, 3_000, sandbox_permissions)?;
|
||||
let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?;
|
||||
Ok((event, Some(command)))
|
||||
}
|
||||
ActionKind::RunCommand { command } => {
|
||||
@@ -1462,7 +1463,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
|
||||
let model = model_override.unwrap_or("gpt-5.1");
|
||||
|
||||
let mut builder = test_codex().with_model(model).with_config(move |config| {
|
||||
config.approval_policy = approval_policy;
|
||||
config.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.sandbox_policy = sandbox_policy.clone();
|
||||
for feature in features {
|
||||
config.features.enable(feature);
|
||||
@@ -1568,7 +1569,7 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts
|
||||
let sandbox_policy = SandboxPolicy::ReadOnly;
|
||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = approval_policy;
|
||||
config.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.sandbox_policy = sandbox_policy_for_config;
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -762,7 +762,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn includes_no_effort_in_request() -> anyhow::Result<()> {
|
||||
async fn includes_default_effort_in_request() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -791,7 +791,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> {
|
||||
.get("reasoning")
|
||||
.and_then(|t| t.get("effort"))
|
||||
.and_then(|v| v.as_str()),
|
||||
None
|
||||
Some("medium")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user