diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 292e549b41..59a1e07fa7 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -65,6 +65,11 @@ jobs: - name: Set up Bazel uses: bazelbuild/setup-bazelisk@v3 + - name: Check MODULE.bazel.lock is up to date + if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' + shell: bash + run: ./scripts/check-module-bazel-lock.sh + # TODO(mbolin): Bring this back once we have caching working. Currently, # we never seem to get a cache hit but we still end up paying the cost of # uploading at the end of the build, which takes over a minute! diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 04e127a785..609c933bd9 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -408,9 +408,8 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh + git clone https://git.code.sf.net/p/zsh/code /tmp/zsh cd /tmp/zsh - git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6 git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" ./Util/preconfig @@ -487,9 +486,8 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh + git clone https://git.code.sf.net/p/zsh/code /tmp/zsh cd /tmp/zsh - git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6 git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" ./Util/preconfig diff --git a/AGENTS.md b/AGENTS.md index fc545c5080..fa2a4bbaba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,10 @@ In the codex-rs folder where the rust code lives: - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. +- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the + repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. +- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught + locally before CI. - Do not create small helper methods that are referenced only once. Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 98efd3cede..80e889a031 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -608,7 +608,7 @@ "anyhow_1.0.101": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.51\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "arbitrary_1.4.2": "{\"dependencies\":[{\"name\":\"derive_arbitrary\",\"optional\":true,\"req\":\"~1.4.0\"},{\"kind\":\"dev\",\"name\":\"exhaustigen\",\"req\":\"^0.1.0\"}],\"features\":{\"derive\":[\"derive_arbitrary\"]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", - "arc-swap_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arc-swap_1.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -1154,8 +1154,8 @@ "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", - "rmcp-macros_0.14.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "rmcp_0.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.14.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", + "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", "rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", diff --git a/README.md b/README.md index ae5073beae..1e44875f21 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@


If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE. +
If you want the desktop app experience, run codex app or visit the Codex App page.
If you are looking for the cloud-based agent from OpenAI, Codex Web, go to chatgpt.com/codex.

--- diff --git a/codex-cli/bin/rg b/codex-cli/bin/rg index 5a992570a9..426df75d71 100755 --- a/codex-cli/bin/rg +++ b/codex-cli/bin/rg @@ -4,74 +4,74 @@ "name": "rg", "platforms": { "macos-aarch64": { - "size": 1787248, - "hash": "blake3", - "digest": "8d9942032585ea8ee805937634238d9aee7b210069f4703c88fbe568e26fb78a", + "size": 1777930, + "hash": "sha256", + "digest": "378e973289176ca0c6054054ee7f631a065874a352bf43f0fa60ef079b6ba715", "format": "tar.gz", - "path": "ripgrep-14.1.1-aarch64-apple-darwin/rg", + "path": "ripgrep-15.1.0-aarch64-apple-darwin/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-apple-darwin.tar.gz" } ] }, "linux-aarch64": { - "size": 2047405, - "hash": "blake3", - "digest": "0b670b8fa0a3df2762af2fc82cc4932f684ca4c02dbd1260d4f3133fd4b2a515", + "size": 1869959, + "hash": "sha256", + "digest": "2b661c6ef508e902f388e9098d9c4c5aca72c87b55922d94abdba830b4dc885e", "format": "tar.gz", - "path": "ripgrep-14.1.1-aarch64-unknown-linux-gnu/rg", + "path": "ripgrep-15.1.0-aarch64-unknown-linux-gnu/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-unknown-linux-gnu.tar.gz" } ] }, "macos-x86_64": { - "size": 2082672, - "hash": "blake3", - "digest": "e9b862fc8da3127f92791f0ff6a799504154ca9d36c98bf3e60a81c6b1f7289e", + "size": 1894127, + "hash": "sha256", + "digest": "64811cb24e77cac3057d6c40b63ac9becf9082eedd54ca411b475b755d334882", "format": "tar.gz", - "path": "ripgrep-14.1.1-x86_64-apple-darwin/rg", + "path": "ripgrep-15.1.0-x86_64-apple-darwin/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-apple-darwin.tar.gz" } ] }, "linux-x86_64": { - "size": 2566310, - "hash": "blake3", - "digest": "f73cca4e54d78c31f832c7f6e2c0b4db8b04fa3eaa747915727d570893dbee76", + "size": 2263077, + "hash": "sha256", + "digest": "1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599", "format": "tar.gz", - "path": "ripgrep-14.1.1-x86_64-unknown-linux-musl/rg", + "path": "ripgrep-15.1.0-x86_64-unknown-linux-musl/rg", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz" } ] }, "windows-x86_64": { - "size": 2058893, - "hash": "blake3", - "digest": "a8ce1a6fed4f8093ee997e57f33254e94b2cd18e26358b09db599c89882eadbd", + "size": 1810687, + "hash": "sha256", + "digest": "124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a", "format": "zip", - "path": "ripgrep-14.1.1-x86_64-pc-windows-msvc/rg.exe", + "path": "ripgrep-15.1.0-x86_64-pc-windows-msvc/rg.exe", "providers": [ { - "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip" } ] }, "windows-aarch64": { - "size": 1667740, - "hash": "blake3", - "digest": "47b971a8c4fca1d23a4e7c19bd4d88465ebc395598458133139406d3bf85f3fa", + "size": 1675460, + "hash": "sha256", + "digest": "00d931fb5237c9696ca49308818edb76d8eb6fc132761cb2a1bd616b2df02f8e", "format": "zip", - "path": "rg.exe", + "path": "ripgrep-15.1.0-aarch64-pc-windows-msvc/rg.exe", "providers": [ { - "url": "https://github.com/microsoft/ripgrep-prebuilt/releases/download/v13.0.0-13/ripgrep-v13.0.0-13-aarch64-pc-windows-msvc.zip" + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-pc-windows-msvc.zip" } ] } diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 224502c38a..189d9468d2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -423,16 +423,16 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] @@ -1227,9 +1227,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.56" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -1237,9 +1237,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.56" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -1271,9 +1271,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clipboard-win" @@ -1712,6 +1712,7 @@ dependencies = [ "include_dir", "indexmap 2.13.0", "indoc", + "insta", "keyring", "landlock", "libc", @@ -3292,7 +3293,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3488,9 +3489,9 @@ checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415" [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -3504,9 +3505,9 @@ checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -3537,7 +3538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4930,7 +4931,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5638,7 +5639,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6204,7 +6205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -6765,7 +6766,7 @@ dependencies = [ "once_cell", "socket2 0.6.2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -7591,7 +7592,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8968,7 +8969,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10328,7 +10329,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 75b3d18d3e..7ad0566efd 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -170,7 +170,7 @@ dotenvy = "0.15.7" dunce = "1.0.4" encoding_rs = "0.8.35" env-flags = "0.1.1" -env_logger = "0.11.5" +env_logger = "0.11.9" eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } globset = "0.4" diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4eec3e9d44..d5ce1e2f87 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -711,6 +711,15 @@ ], "type": "object" }, + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, "InitializeCapabilities": { "description": "Client-declared capabilities negotiated during initialize.", "properties": { @@ -1128,6 +1137,13 @@ "null" ] }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -1243,6 +1259,15 @@ ], "type": "string" }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + }, "ReadOnlyAccess": { "oneOf": [ { @@ -2414,20 +2439,38 @@ "type": "object" }, "SkillsRemoteReadParams": { + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/ProductSurface" + } + ], + "default": "codex" + } + }, "type": "object" }, "SkillsRemoteWriteParams": { "properties": { "hazelnutId": { "type": "string" - }, - "isPreload": { - "type": "boolean" } }, "required": [ - "hazelnutId", - "isPreload" + "hazelnutId" ], "type": "object" }, @@ -3592,9 +3635,9 @@ }, "method": { "enum": [ - "skills/remote/read" + "skills/remote/list" ], - "title": "Skills/remote/readRequestMethod", + "title": "Skills/remote/listRequestMethod", "type": "string" }, "params": { @@ -3606,7 +3649,7 @@ "method", "params" ], - "title": "Skills/remote/readRequest", + "title": "Skills/remote/listRequest", "type": "object" }, { @@ -3616,9 +3659,9 @@ }, "method": { "enum": [ - "skills/remote/write" + "skills/remote/export" ], - "title": "Skills/remote/writeRequestMethod", + "title": "Skills/remote/exportRequestMethod", "type": "string" }, "params": { @@ -3630,7 +3673,7 @@ "method", "params" ], - "title": "Skills/remote/writeRequest", + "title": "Skills/remote/exportRequest", "type": "object" }, { @@ -4713,4 +4756,4 @@ } ], "title": "ClientRequest" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 6af66294c8..5ec61285ca 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -1438,6 +1467,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3307,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -3315,6 +3361,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { @@ -5343,6 +5413,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -6308,6 +6407,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" diff --git a/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json new file mode 100644 index 0000000000..c8924e77ca --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 3277e5650f..085bbee050 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -165,9 +165,71 @@ } ] }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -198,6 +260,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -235,6 +306,130 @@ ], "type": "object" }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, "AskForApproval": { "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", "oneOf": [ @@ -1094,6 +1289,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason2" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -2059,6 +2283,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3752,6 +3987,17 @@ ], "type": "object" }, + "FuzzyFileSearchSessionCompletedNotification": { + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" + }, "FuzzyFileSearchSessionUpdatedNotification": { "properties": { "files": { @@ -4188,6 +4434,45 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelRerouteReason2": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "type": "object" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -4196,6 +4481,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { @@ -8230,6 +8539,26 @@ "title": "Thread/compactedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, { "properties": { "method": { @@ -8290,6 +8619,26 @@ "title": "FuzzyFileSearch/sessionUpdatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionCompleted" + ], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, { "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d3a65af96e..4e2756e486 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -755,9 +755,9 @@ }, "method": { "enum": [ - "skills/remote/read" + "skills/remote/list" ], - "title": "Skills/remote/readRequestMethod", + "title": "Skills/remote/listRequestMethod", "type": "string" }, "params": { @@ -769,7 +769,7 @@ "method", "params" ], - "title": "Skills/remote/readRequest", + "title": "Skills/remote/listRequest", "type": "object" }, { @@ -779,9 +779,9 @@ }, "method": { "enum": [ - "skills/remote/write" + "skills/remote/export" ], - "title": "Skills/remote/writeRequestMethod", + "title": "Skills/remote/exportRequestMethod", "type": "string" }, "params": { @@ -793,7 +793,7 @@ "method", "params" ], - "title": "Skills/remote/writeRequest", + "title": "Skills/remote/exportRequest", "type": "object" }, { @@ -2486,6 +2486,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/v2/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3451,6 +3480,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -5412,6 +5452,19 @@ ], "type": "object" }, + "FuzzyFileSearchSessionCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" + }, "FuzzyFileSearchSessionUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -6242,6 +6295,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -6250,6 +6309,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "NewConversationParams": { "properties": { "approvalPolicy": { @@ -8443,6 +8526,26 @@ "title": "Thread/compactedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, { "properties": { "method": { @@ -8503,6 +8606,26 @@ "title": "FuzzyFileSearch/sessionUpdatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionCompleted" + ], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, { "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", "properties": { @@ -10148,6 +10271,48 @@ }, "type": "object" }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppConfig": { "properties": { "disable_destructive": { @@ -10200,6 +10365,26 @@ "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -10230,6 +10415,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -10269,6 +10463,130 @@ "title": "AppListUpdatedNotification", "type": "object" }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/v2/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, "AppToolApproval": { "enum": [ "auto", @@ -12164,6 +12482,15 @@ }, "type": "object" }, + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, "InputModality": { "description": "Canonical user-input modality tags advertised by a model.", "oneOf": [ @@ -12696,6 +13023,9 @@ "displayName": { "type": "string" }, + "hidden": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -12736,6 +13066,7 @@ "defaultReasoningEffort", "description", "displayName", + "hidden", "id", "isDefault", "model", @@ -12753,6 +13084,13 @@ "null" ] }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -12789,6 +13127,41 @@ "title": "ModelListResponse", "type": "object" }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/v2/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", @@ -13006,6 +13379,15 @@ ], "type": "string" }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + }, "ProfileV2": { "additionalProperties": true, "properties": { @@ -14537,6 +14919,28 @@ }, "SkillsRemoteReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/v2/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/v2/ProductSurface" + } + ], + "default": "codex" + } + }, "title": "SkillsRemoteReadParams", "type": "object" }, @@ -14561,14 +14965,10 @@ "properties": { "hazelnutId": { "type": "string" - }, - "isPreload": { - "type": "boolean" } }, "required": [ - "hazelnutId", - "isPreload" + "hazelnutId" ], "title": "SkillsRemoteWriteParams", "type": "object" @@ -14579,16 +14979,12 @@ "id": { "type": "string" }, - "name": { - "type": "string" - }, "path": { "type": "string" } }, "required": [ "id", - "name", "path" ], "title": "SkillsRemoteWriteResponse", diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 12db241e1a..a73c550f69 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -1438,6 +1467,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3307,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -3315,6 +3361,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index dd7606b5e0..6dc02934d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -1438,6 +1467,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3307,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -3315,6 +3361,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 89ab825d32..69967e6853 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -1438,6 +1467,17 @@ "description": "The command's working directory.", "type": "string" }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, "parsed_cmd": { "items": { "$ref": "#/definitions/ParsedCommand" @@ -3307,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -3315,6 +3361,30 @@ ], "type": "string" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5_tcp", + "socks5_udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index 1638472c4e..0813ed6f56 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -1,9 +1,71 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -34,6 +96,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -55,6 +126,130 @@ "name" ], "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" } }, "description": "EXPERIMENTAL - notification emitted when the app list changes.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index cec3da5ccc..4697b34e12 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -1,9 +1,71 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -34,6 +96,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -55,6 +126,130 @@ "name" ], "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" } }, "description": "EXPERIMENTAL - app list response.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json index 13bb29977e..11a3476240 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListParams.json @@ -8,6 +8,13 @@ "null" ] }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index a1b3f7114c..2afa018dc5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -31,6 +31,9 @@ "displayName": { "type": "string" }, + "hidden": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -71,6 +74,7 @@ "defaultReasoningEffort", "description", "displayName", + "hidden", "id", "isDefault", "model", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json new file mode 100644 index 0000000000..b9bcc491b0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + } + }, + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json index ace2fb5ba7..3fdf78f92b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json @@ -1,5 +1,47 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + } + }, + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/ProductSurface" + } + ], + "default": "codex" + } + }, "title": "SkillsRemoteReadParams", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json index 871a5a428a..f1a70eeeb0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json @@ -3,14 +3,10 @@ "properties": { "hazelnutId": { "type": "string" - }, - "isPreload": { - "type": "boolean" } }, "required": [ - "hazelnutId", - "isPreload" + "hazelnutId" ], "title": "SkillsRemoteWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json index 1a9473d054..d1e85bc5ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json @@ -4,18 +4,14 @@ "id": { "type": "string" }, - "name": { - "type": "string" - }, "path": { "type": "string" } }, "required": [ "id", - "name", "path" ], "title": "SkillsRemoteWriteResponse", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 3b5f6787b1..40757d9d95 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -57,4 +57,4 @@ import type { TurnSteerParams } from "./v2/TurnSteerParams"; /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index 2c0514870f..5ed3ca2529 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -42,6 +42,7 @@ import type { McpStartupCompleteEvent } from "./McpStartupCompleteEvent"; import type { McpStartupUpdateEvent } from "./McpStartupUpdateEvent"; import type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; import type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; +import type { ModelRerouteEvent } from "./ModelRerouteEvent"; import type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent"; import type { PatchApplyEndEvent } from "./PatchApplyEndEvent"; import type { PlanDeltaEvent } from "./PlanDeltaEvent"; @@ -74,4 +75,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent"; * Response event from the agent * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; +export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts index 66db8fd40a..542ed879b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; +import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { ParsedCommand } from "./ParsedCommand"; export type ExecApprovalRequestEvent = { @@ -26,6 +27,10 @@ cwd: string, * Optional human-readable reason for the approval (e.g. retry without sandbox). */ reason: string | null, +/** + * Optional network context for a blocked request that can be approved. + */ +network_approval_context?: NetworkApprovalContext, /** * Proposed execpolicy amendment that can be applied to allow future runs. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts new file mode 100644 index 0000000000..f4dc7fac11 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FuzzyFileSearchSessionCompletedNotification = { sessionId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts new file mode 100644 index 0000000000..23a4e1efb6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelRerouteReason } from "./ModelRerouteReason"; + +export type ModelRerouteEvent = { from_model: string, to_model: string, reason: ModelRerouteReason, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts new file mode 100644 index 0000000000..f5e1abf1e3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelRerouteReason = "high_risk_cyber_activity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts new file mode 100644 index 0000000000..b4b78e473c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; + +export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts new file mode 100644 index 0000000000..a33eab566f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkApprovalProtocol = "http" | "https" | "socks5_tcp" | "socks5_udp"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 0e49c060c0..171da74257 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AuthStatusChangeNotification } from "./AuthStatusChangeNotification"; +import type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; import type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification"; import type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; @@ -20,6 +21,7 @@ import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; import type { ItemStartedNotification } from "./v2/ItemStartedNotification"; import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; +import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; @@ -38,4 +40,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 2e7446a3db..2a626d3638 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -78,6 +78,7 @@ export type { FunctionCallOutputPayload } from "./FunctionCallOutputPayload"; export type { FuzzyFileSearchParams } from "./FuzzyFileSearchParams"; export type { FuzzyFileSearchResponse } from "./FuzzyFileSearchResponse"; export type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult"; +export type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; export type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; export type { GetAuthStatusParams } from "./GetAuthStatusParams"; export type { GetAuthStatusResponse } from "./GetAuthStatusResponse"; @@ -124,7 +125,11 @@ export type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; export type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; +export type { ModelRerouteEvent } from "./ModelRerouteEvent"; +export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { NetworkAccess } from "./NetworkAccess"; +export type { NetworkApprovalContext } from "./NetworkApprovalContext"; +export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NewConversationParams } from "./NewConversationParams"; export type { NewConversationResponse } from "./NewConversationResponse"; export type { ParsedCommand } from "./ParsedCommand"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts new file mode 100644 index 0000000000..873398db67 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - app metadata returned by app-list APIs. + */ +export type AppBranding = { category: string | null, developer: string | null, website: string | null, privacyPolicy: string | null, termsOfService: string | null, isDiscoverableApp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index 2f65c18327..0c9a13b124 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -1,11 +1,13 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppBranding } from "./AppBranding"; +import type { AppMetadata } from "./AppMetadata"; /** * EXPERIMENTAL - app metadata returned by app-list APIs. */ -export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, +export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, branding: AppBranding | null, appMetadata: AppMetadata | null, labels: { [key in string]?: string } | null, installUrl: string | null, isAccessible: boolean, /** * Whether this app is enabled in config.toml. * Example: diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts new file mode 100644 index 0000000000..f1a5001eb1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppReview } from "./AppReview"; +import type { AppScreenshot } from "./AppScreenshot"; + +export type AppMetadata = { review: AppReview | null, categories: Array | null, subCategories: Array | null, seoDescription: string | null, screenshots: Array | null, developer: string | null, version: string | null, versionId: string | null, versionNotes: string | null, firstPartyType: string | null, firstPartyRequiresInstall: boolean | null, showInComposerWhenUnlinked: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppReview.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppReview.ts new file mode 100644 index 0000000000..10fd95f09d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppReview.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppReview = { status: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts new file mode 100644 index 0000000000..0d264246f3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppScreenshot = { url: string | null, fileId: string | null, userPrompt: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts new file mode 100644 index 0000000000..e623f1860b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HazelnutScope = "example" | "workspace-shared" | "all-shared" | "personal"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index 7528a8fad3..de7b240173 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -5,4 +5,4 @@ import type { InputModality } from "../InputModality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningEffortOption } from "./ReasoningEffortOption"; -export type Model = { id: string, model: string, upgrade: string | null, displayName: string, description: string, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, isDefault: boolean, }; +export type Model = { id: string, model: string, upgrade: string | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts index b0bc5326c1..a1e7b8fa74 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelListParams.ts @@ -10,4 +10,8 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ -limit?: number | null, }; +limit?: number | null, +/** + * When true, include models that are hidden from the default picker list. + */ +includeHidden?: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts new file mode 100644 index 0000000000..e780e7f95d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelRerouteReason = "highRiskCyberActivity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts new file mode 100644 index 0000000000..9b6b2e524a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelRerouteReason } from "./ModelRerouteReason"; + +export type ModelReroutedNotification = { threadId: string, turnId: string, fromModel: string, toModel: string, reason: ModelRerouteReason, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts new file mode 100644 index 0000000000..9998c727a8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProductSurface = "chatgpt" | "codex" | "api" | "atlas"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts index 9f91787686..1257f0d791 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts @@ -1,5 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HazelnutScope } from "./HazelnutScope"; +import type { ProductSurface } from "./ProductSurface"; -export type SkillsRemoteReadParams = Record; +export type SkillsRemoteReadParams = { hazelnutScope: HazelnutScope, productSurface: ProductSurface, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts index 857b609ef1..ea42595bfd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SkillsRemoteWriteParams = { hazelnutId: string, isPreload: boolean, }; +export type SkillsRemoteWriteParams = { hazelnutId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts index cf1665ab97..228b723b19 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SkillsRemoteWriteResponse = { id: string, name: string, path: string, }; +export type SkillsRemoteWriteResponse = { id: string, path: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 54322bf839..1ac4e8f3c2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -6,9 +6,13 @@ export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUp export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; +export type { AppBranding } from "./AppBranding"; export type { AppDisabledReason } from "./AppDisabledReason"; export type { AppInfo } from "./AppInfo"; export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; +export type { AppMetadata } from "./AppMetadata"; +export type { AppReview } from "./AppReview"; +export type { AppScreenshot } from "./AppScreenshot"; export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolDefaults } from "./AppToolDefaults"; export type { AppToolsConfig } from "./AppToolsConfig"; @@ -74,6 +78,7 @@ export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GitInfo } from "./GitInfo"; +export type { HazelnutScope } from "./HazelnutScope"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemStartedNotification } from "./ItemStartedNotification"; export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams"; @@ -95,12 +100,15 @@ export type { MergeStrategy } from "./MergeStrategy"; export type { Model } from "./Model"; export type { ModelListParams } from "./ModelListParams"; export type { ModelListResponse } from "./ModelListResponse"; +export type { ModelRerouteReason } from "./ModelRerouteReason"; +export type { ModelReroutedNotification } from "./ModelReroutedNotification"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkRequirements } from "./NetworkRequirements"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; +export type { ProductSurface } from "./ProductSurface"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bc91341166..88a17390de 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -239,11 +239,11 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, - SkillsRemoteRead => "skills/remote/read" { + SkillsRemoteList => "skills/remote/list" { params: v2::SkillsRemoteReadParams, response: v2::SkillsRemoteReadResponse, }, - SkillsRemoteWrite => "skills/remote/write" { + SkillsRemoteExport => "skills/remote/export" { params: v2::SkillsRemoteWriteParams, response: v2::SkillsRemoteWriteResponse, }, @@ -758,6 +758,13 @@ pub struct FuzzyFileSearchSessionUpdatedNotification { pub files: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchSessionCompletedNotification { + pub session_id: String, +} + server_notification_definitions! { /// NEW NOTIFICATIONS Error => "error" (v2::ErrorNotification), @@ -788,9 +795,11 @@ server_notification_definitions! { ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification), /// Deprecated: Use `ContextCompaction` item type instead. ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), + ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), + FuzzyFileSearchSessionCompleted => "fuzzyFileSearch/sessionCompleted" (FuzzyFileSearchSessionCompletedNotification), /// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox. WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification), @@ -1227,7 +1236,8 @@ mod tests { "id": 6, "params": { "limit": null, - "cursor": null + "cursor": null, + "includeHidden": null } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4152f5da20..6c1947c6dd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -30,6 +30,7 @@ use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; @@ -229,6 +230,12 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum ModelRerouteReason from CoreModelRerouteReason { + HighRiskCyberActivity + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -1164,6 +1171,9 @@ pub struct ModelListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// When true, include models that are hidden from the default picker list. + #[ts(optional = nullable)] + pub include_hidden: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1175,6 +1185,7 @@ pub struct Model { pub upgrade: Option, pub display_name: String, pub description: String, + pub hidden: bool, pub supported_reasoning_efforts: Vec, pub default_reasoning_effort: ReasoningEffort, #[serde(default = "default_input_modalities")] @@ -1330,6 +1341,55 @@ pub struct AppsListParams { pub force_refetch: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppBranding { + pub category: Option, + pub developer: Option, + pub website: Option, + pub privacy_policy: Option, + pub terms_of_service: Option, + pub is_discoverable_app: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppReview { + pub status: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppScreenshot { + pub url: Option, + #[serde(alias = "file_id")] + pub file_id: Option, + #[serde(alias = "user_prompt")] + pub user_prompt: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppMetadata { + pub review: Option, + pub categories: Option>, + pub sub_categories: Option>, + pub seo_description: Option, + pub screenshots: Option>, + pub developer: Option, + pub version: Option, + pub version_id: Option, + pub version_notes: Option, + pub first_party_type: Option, + pub first_party_requires_install: Option, + pub show_in_composer_when_unlinked: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1341,6 +1401,9 @@ pub struct AppInfo { pub logo_url: Option, pub logo_url_dark: Option, pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, pub install_url: Option, #[serde(default)] pub is_accessible: bool, @@ -1880,7 +1943,38 @@ pub struct SkillsListResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct SkillsRemoteReadParams {} +pub struct SkillsRemoteReadParams { + #[serde(default)] + pub hazelnut_scope: HazelnutScope, + #[serde(default)] + pub product_surface: ProductSurface, + #[serde(default)] + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case")] +#[ts(export_to = "v2/")] +pub enum HazelnutScope { + #[default] + Example, + WorkspaceShared, + AllShared, + Personal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum ProductSurface { + Chatgpt, + #[default] + Codex, + Api, + Atlas, +} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -1903,7 +1997,6 @@ pub struct SkillsRemoteReadResponse { #[ts(export_to = "v2/")] pub struct SkillsRemoteWriteParams { pub hazelnut_id: String, - pub is_preload: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1911,7 +2004,6 @@ pub struct SkillsRemoteWriteParams { #[ts(export_to = "v2/")] pub struct SkillsRemoteWriteResponse { pub id: String, - pub name: String, pub path: PathBuf, } @@ -3355,6 +3447,17 @@ pub struct AccountLoginCompletedNotification { pub error: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelReroutedNotification { + pub thread_id: String, + pub turn_id: String, + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/README.md b/codex-rs/app-server-test-client/README.md index 5ef6e2344c..ab11f12353 100644 --- a/codex-rs/app-server-test-client/README.md +++ b/codex-rs/app-server-test-client/README.md @@ -17,3 +17,33 @@ cargo run -p codex-app-server-test-client -- \ # 3) Call app-server (defaults to ws://127.0.0.1:4222) cargo run -p codex-app-server-test-client -- model-list ``` + +## Testing Thread Rejoin Behavior + +Build and start an app server using commands above. The app-server log is written to `/tmp/codex-app-server-test-client/app-server.log` + +### 1) Get a thread id + +Create at least one thread, then list threads: + +```bash +cargo run -p codex-app-server-test-client -- send-message-v2 "seed thread for rejoin test" +cargo run -p codex-app-server-test-client -- thread-list --limit 5 +``` + +Copy a thread id from the `thread-list` output. + +### 2) Rejoin while a turn is in progress (two terminals) + +Terminal A: + +```bash +cargo run --bin codex-app-server-test-client -- \ + resume-message-v2 "respond with thorough docs on the rust core" +``` + +Terminal B (while Terminal A is still streaming): + +```bash +cargo run --bin codex-app-server-test-client -- thread-resume +``` diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c798073056..ef34e3f6a7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -131,12 +131,12 @@ Example with notification opt-out: - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). -- `model/list` — list available models (with reasoning effort options and optional `upgrade` model ids). +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options and optional `upgrade` model ids. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `skills/remote/read` — list public remote skills (**under development; do not call from production clients yet**). -- `skills/remote/write` — download a public remote skill by `hazelnutId`; `isPreload=true` writes to `.codex/vendor_imports/skills` under `codex_home` (**under development; do not call from production clients yet**). +- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**). +- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). - `app/list` — list available apps. - `skills/config/write` — write user-level skill config by path. - `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. @@ -535,6 +535,13 @@ Examples: - Opt out of legacy session setup event: `codex/event/session_configured` - Opt out of streamed agent text deltas: `item/agentMessage/delta` +### Fuzzy file search events (experimental) + +The fuzzy file search session API emits per-query notifications: + +- `fuzzyFileSearch/sessionUpdated` — `{ sessionId, query, files }` with the current matching files for the active query. +- `fuzzyFileSearch/sessionCompleted` — `{ sessionId, query }` once indexing/matching for that query has completed. + ### Turn events The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. @@ -543,6 +550,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. +- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. @@ -764,7 +772,7 @@ To enable or disable a skill by path: ## Apps -Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config. +Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. ```json { "method": "app/list", "id": 50, "params": { @@ -782,6 +790,9 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat "logoUrl": "https://example.com/demo-app.png", "logoUrlDark": null, "distributionChannel": null, + "branding": null, + "appMetadata": null, + "labels": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", "isAccessible": true, "isEnabled": true @@ -809,6 +820,9 @@ The server also emits `app/list/updated` notifications whenever either source (a "logoUrl": "https://example.com/demo-app.png", "logoUrlDark": null, "distributionChannel": null, + "branding": null, + "appMetadata": null, + "labels": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", "isAccessible": true, "isEnabled": true diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c1f36e1ef3..3acdefc731 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::PlanDeltaNotification; @@ -122,6 +123,21 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::TurnComplete(_ev) => { handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; } + EventMsg::Warning(_warning_event) => {} + EventMsg::ModelReroute(event) => { + if let ApiVersion::V2 = api_version { + let notification = ModelReroutedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + from_model: event.from_model, + to_model: event.to_model, + reason: event.reason.into(), + }; + outgoing + .send_server_notification(ServerNotification::ModelRerouted(notification)) + .await; + } + } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, turn_id, @@ -206,6 +222,7 @@ pub(crate) async fn apply_bespoke_event_handling( reason, proposed_execpolicy_amendment, parsed_cmd, + .. }) => match api_version { ApiVersion::V1 => { let params = ExecCommandApprovalParams { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 198892d0a2..45d8af7746 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -66,6 +66,7 @@ use codex_app_server_protocol::GetUserAgentResponse; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::HazelnutScope as ApiHazelnutScope; use codex_app_server_protocol::InputItem as WireInputItem; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; @@ -92,6 +93,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; +use codex_app_server_protocol::ProductSurface as ApiProductSurface; use codex_app_server_protocol::RemoveConversationListenerParams; use codex_app_server_protocol::RemoveConversationSubscriptionResponse; use codex_app_server_protocol::ResumeConversationParams; @@ -207,7 +209,7 @@ use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; -use codex_core::skills::remote::download_remote_skill; +use codex_core::skills::remote::export_remote_skill; use codex_core::skills::remote::list_remote_skills; use codex_core::state_db::StateDbHandle; use codex_core::state_db::get_state_db; @@ -229,6 +231,8 @@ use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RemoteSkillHazelnutScope; +use codex_protocol::protocol::RemoteSkillProductSurface; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; @@ -291,6 +295,24 @@ enum AppListLoadResult { Directory(Result, String>), } +fn convert_remote_scope(scope: ApiHazelnutScope) -> RemoteSkillHazelnutScope { + match scope { + ApiHazelnutScope::WorkspaceShared => RemoteSkillHazelnutScope::WorkspaceShared, + ApiHazelnutScope::AllShared => RemoteSkillHazelnutScope::AllShared, + ApiHazelnutScope::Personal => RemoteSkillHazelnutScope::Personal, + ApiHazelnutScope::Example => RemoteSkillHazelnutScope::Example, + } +} + +fn convert_remote_product_surface(product_surface: ApiProductSurface) -> RemoteSkillProductSurface { + match product_surface { + ApiProductSurface::Chatgpt => RemoteSkillProductSurface::Chatgpt, + ApiProductSurface::Codex => RemoteSkillProductSurface::Codex, + ApiProductSurface::Api => RemoteSkillProductSurface::Api, + ApiProductSurface::Atlas => RemoteSkillProductSurface::Atlas, + } +} + impl Drop for ActiveLogin { fn drop(&mut self) { self.shutdown_handle.shutdown(); @@ -549,12 +571,12 @@ impl CodexMessageProcessor { self.skills_list(to_connection_request_id(request_id), params) .await; } - ClientRequest::SkillsRemoteRead { request_id, params } => { - self.skills_remote_read(to_connection_request_id(request_id), params) + ClientRequest::SkillsRemoteList { request_id, params } => { + self.skills_remote_list(to_connection_request_id(request_id), params) .await; } - ClientRequest::SkillsRemoteWrite { request_id, params } => { - self.skills_remote_write(to_connection_request_id(request_id), params) + ClientRequest::SkillsRemoteExport { request_id, params } => { + self.skills_remote_export(to_connection_request_id(request_id), params) .await; } ClientRequest::AppsList { request_id, params } => { @@ -599,11 +621,10 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { let outgoing = self.outgoing.clone(); let thread_manager = self.thread_manager.clone(); - let config = self.config.clone(); let request_id = to_connection_request_id(request_id); tokio::spawn(async move { - Self::list_models(outgoing, thread_manager, config, request_id, params).await; + Self::list_models(outgoing, thread_manager, request_id, params).await; }); } ClientRequest::ExperimentalFeatureList { request_id, params } => { @@ -1688,8 +1709,18 @@ impl CodexMessageProcessor { let timeout_ms = params .timeout_ms .and_then(|timeout_ms| u64::try_from(timeout_ms).ok()); + let managed_network_requirements_enabled = + self.config.managed_network_requirements_enabled(); let started_network_proxy = match self.config.permissions.network.as_ref() { - Some(spec) => match spec.start_proxy().await { + Some(spec) => match spec + .start_proxy( + self.config.permissions.sandbox_policy.get(), + None, + None, + managed_network_requirements_enabled, + ) + .await + { Ok(started) => Some(started), Err(err) => { let error = JSONRPCErrorError { @@ -1712,6 +1743,7 @@ impl CodexMessageProcessor { network: started_network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy), + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, justification: None, @@ -2689,6 +2721,13 @@ impl CodexMessageProcessor { } async fn thread_resume(&mut self, request_id: ConnectionRequestId, params: ThreadResumeParams) { + if self + .resume_running_thread(request_id.clone(), ¶ms) + .await + { + return; + } + let ThreadResumeParams { thread_id, history, @@ -2706,77 +2745,21 @@ impl CodexMessageProcessor { } = params; let thread_history = if let Some(history) = history { - if history.is_empty() { - self.send_invalid_request_error( - request_id, - "history must not be empty".to_string(), - ) - .await; + let Some(thread_history) = self + .resume_thread_from_history(request_id.clone(), history.as_slice()) + .await + else { return; - } - InitialHistory::Forked(history.into_iter().map(RolloutItem::ResponseItem).collect()) - } else if let Some(path) = path { - match RolloutRecorder::get_rollout_history(&path).await { - Ok(initial_history) => initial_history, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to load rollout `{}`: {err}", path.display()), - ) - .await; - return; - } - } + }; + thread_history } else { - let existing_thread_id = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } + let Some(thread_history) = self + .resume_thread_from_rollout(request_id.clone(), &thread_id, path.as_ref()) + .await + else { + return; }; - - let path = match find_thread_path_by_id_str( - &self.config.codex_home, - &existing_thread_id.to_string(), - ) - .await - { - Ok(Some(p)) => p, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {existing_thread_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate thread id {existing_thread_id}: {err}"), - ) - .await; - return; - } - }; - - match RolloutRecorder::get_rollout_history(&path).await { - Ok(initial_history) => initial_history, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to load rollout `{}`: {err}", path.display()), - ) - .await; - return; - } - } + thread_history }; let history_cwd = thread_history.session_cwd(); @@ -2857,41 +2840,17 @@ impl CodexMessageProcessor { ); } - let mut thread = match read_summary_from_rollout( - rollout_path.as_path(), - fallback_model_provider.as_str(), - ) - .await - { - Ok(summary) => summary_to_thread(summary), - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; - } + let Some(thread) = self + .load_thread_from_rollout_or_send_internal( + request_id.clone(), + thread_id, + rollout_path.as_path(), + fallback_model_provider.as_str(), + ) + .await + else { + return; }; - match read_rollout_items_from_rollout(rollout_path.as_path()).await { - Ok(items) => { - thread.turns = build_turns_from_rollout_items(&items); - } - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; - } - } let response = ThreadResumeResponse { thread, @@ -2916,6 +2875,278 @@ impl CodexMessageProcessor { } } + async fn resume_running_thread( + &mut self, + request_id: ConnectionRequestId, + params: &ThreadResumeParams, + ) -> bool { + if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) + && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await + { + if params.history.is_some() { + self.send_invalid_request_error( + request_id, + format!( + "cannot resume thread {existing_thread_id} with history while it is already running" + ), + ) + .await; + return true; + } + + let rollout_path = if let Some(path) = existing_thread.rollout_path() { + if path.exists() { + path + } else { + match find_thread_path_by_id_str( + &self.config.codex_home, + &existing_thread_id.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for thread id {existing_thread_id}"), + ) + .await; + return true; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {existing_thread_id}: {err}"), + ) + .await; + return true; + } + } + } + } else { + match find_thread_path_by_id_str( + &self.config.codex_home, + &existing_thread_id.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for thread id {existing_thread_id}"), + ) + .await; + return true; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {existing_thread_id}: {err}"), + ) + .await; + return true; + } + } + }; + + if let Some(requested_path) = params.path.as_ref() + && requested_path != &rollout_path + { + self.send_invalid_request_error( + request_id, + format!( + "cannot resume running thread {existing_thread_id} with mismatched path: requested `{}`, active `{}`", + requested_path.display(), + rollout_path.display() + ), + ) + .await; + return true; + } + + if let Err(err) = self + .ensure_conversation_listener( + existing_thread_id, + request_id.connection_id, + false, + ApiVersion::V2, + ) + .await + { + tracing::warn!( + "failed to attach listener for thread {}: {}", + existing_thread_id, + err.message + ); + } + + let config_snapshot = existing_thread.config_snapshot().await; + let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); + if !mismatch_details.is_empty() { + tracing::warn!( + "thread/resume overrides ignored for running thread {}: {}", + existing_thread_id, + mismatch_details.join("; ") + ); + } + + let Some(thread) = self + .load_thread_from_rollout_or_send_internal( + request_id.clone(), + existing_thread_id, + rollout_path.as_path(), + config_snapshot.model_provider_id.as_str(), + ) + .await + else { + return true; + }; + + let ThreadConfigSnapshot { + model, + model_provider_id, + approval_policy, + sandbox_policy, + cwd, + reasoning_effort, + .. + } = config_snapshot; + let response = ThreadResumeResponse { + thread, + model, + model_provider: model_provider_id, + cwd, + approval_policy: approval_policy.into(), + sandbox: sandbox_policy.into(), + reasoning_effort, + }; + self.outgoing.send_response(request_id, response).await; + return true; + } + false + } + + async fn resume_thread_from_history( + &self, + request_id: ConnectionRequestId, + history: &[ResponseItem], + ) -> Option { + if history.is_empty() { + self.send_invalid_request_error(request_id, "history must not be empty".to_string()) + .await; + return None; + } + Some(InitialHistory::Forked( + history + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(), + )) + } + + async fn resume_thread_from_rollout( + &self, + request_id: ConnectionRequestId, + thread_id: &str, + path: Option<&PathBuf>, + ) -> Option { + let rollout_path = if let Some(path) = path { + path.clone() + } else { + let existing_thread_id = match ThreadId::from_string(thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return None; + } + }; + + match find_thread_path_by_id_str( + &self.config.codex_home, + &existing_thread_id.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for thread id {existing_thread_id}"), + ) + .await; + return None; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate thread id {existing_thread_id}: {err}"), + ) + .await; + return None; + } + } + }; + + match RolloutRecorder::get_rollout_history(&rollout_path).await { + Ok(initial_history) => Some(initial_history), + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to load rollout `{}`: {err}", rollout_path.display()), + ) + .await; + None + } + } + } + + async fn load_thread_from_rollout_or_send_internal( + &self, + request_id: ConnectionRequestId, + thread_id: ThreadId, + rollout_path: &Path, + fallback_provider: &str, + ) -> Option { + let mut thread = match read_summary_from_rollout(rollout_path, fallback_provider).await { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_id}: {err}", + rollout_path.display() + ), + ) + .await; + return None; + } + }; + match read_rollout_items_from_rollout(rollout_path).await { + Ok(items) => { + thread.turns = build_turns_from_rollout_items(&items); + Some(thread) + } + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_id}: {err}", + rollout_path.display() + ), + ) + .await; + None + } + } + } + async fn thread_fork(&mut self, request_id: ConnectionRequestId, params: ThreadForkParams) { let ThreadForkParams { thread_id, @@ -3389,14 +3620,15 @@ impl CodexMessageProcessor { async fn list_models( outgoing: Arc, thread_manager: Arc, - config: Arc, request_id: ConnectionRequestId, params: ModelListParams, ) { - let ModelListParams { limit, cursor } = params; - let mut config = (*config).clone(); - config.features.enable(Feature::RemoteModels); - let models = supported_models(thread_manager, &config).await; + let ModelListParams { + limit, + cursor, + include_hidden, + } = params; + let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; let total = models.len(); if total == 0 { @@ -4833,12 +5065,25 @@ impl CodexMessageProcessor { .await; } - async fn skills_remote_read( + async fn skills_remote_list( &self, request_id: ConnectionRequestId, - _params: SkillsRemoteReadParams, + params: SkillsRemoteReadParams, ) { - match list_remote_skills(&self.config).await { + let hazelnut_scope = convert_remote_scope(params.hazelnut_scope); + let product_surface = convert_remote_product_surface(params.product_surface); + let enabled = if params.enabled { Some(true) } else { None }; + + let auth = self.auth_manager.auth().await; + match list_remote_skills( + &self.config, + auth.as_ref(), + hazelnut_scope, + product_surface, + enabled, + ) + .await + { Ok(skills) => { let data = skills .into_iter() @@ -4855,23 +5100,21 @@ impl CodexMessageProcessor { Err(err) => { self.send_internal_error( request_id, - format!("failed to read remote skills: {err}"), + format!("failed to list remote skills: {err}"), ) .await; } } } - async fn skills_remote_write( + async fn skills_remote_export( &self, request_id: ConnectionRequestId, params: SkillsRemoteWriteParams, ) { - let SkillsRemoteWriteParams { - hazelnut_id, - is_preload, - } = params; - let response = download_remote_skill(&self.config, hazelnut_id.as_str(), is_preload).await; + let SkillsRemoteWriteParams { hazelnut_id } = params; + let auth = self.auth_manager.auth().await; + let response = export_remote_skill(&self.config, auth.as_ref(), hazelnut_id.as_str()).await; match response { Ok(downloaded) => { @@ -4880,7 +5123,6 @@ impl CodexMessageProcessor { request_id, SkillsRemoteWriteResponse { id: downloaded.id, - name: downloaded.name, path: downloaded.path, }, ) @@ -5808,6 +6050,101 @@ impl CodexMessageProcessor { } } +fn collect_resume_override_mismatches( + request: &ThreadResumeParams, + config_snapshot: &ThreadConfigSnapshot, +) -> Vec { + let mut mismatch_details = Vec::new(); + + if let Some(requested_model) = request.model.as_deref() + && requested_model != config_snapshot.model + { + mismatch_details.push(format!( + "model requested={requested_model} active={}", + config_snapshot.model + )); + } + if let Some(requested_provider) = request.model_provider.as_deref() + && requested_provider != config_snapshot.model_provider_id + { + mismatch_details.push(format!( + "model_provider requested={requested_provider} active={}", + config_snapshot.model_provider_id + )); + } + if let Some(requested_cwd) = request.cwd.as_deref() { + let requested_cwd_path = std::path::PathBuf::from(requested_cwd); + if requested_cwd_path != config_snapshot.cwd { + mismatch_details.push(format!( + "cwd requested={} active={}", + requested_cwd_path.display(), + config_snapshot.cwd.display() + )); + } + } + if let Some(requested_approval) = request.approval_policy.as_ref() { + let active_approval: AskForApproval = config_snapshot.approval_policy.into(); + if requested_approval != &active_approval { + mismatch_details.push(format!( + "approval_policy requested={requested_approval:?} active={active_approval:?}" + )); + } + } + if let Some(requested_sandbox) = request.sandbox.as_ref() { + let sandbox_matches = matches!( + (requested_sandbox, &config_snapshot.sandbox_policy), + ( + SandboxMode::ReadOnly, + codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } + ) | ( + SandboxMode::WorkspaceWrite, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + ) + ); + if !sandbox_matches { + mismatch_details.push(format!( + "sandbox requested={requested_sandbox:?} active={:?}", + config_snapshot.sandbox_policy + )); + } + } + if let Some(requested_personality) = request.personality.as_ref() + && config_snapshot.personality.as_ref() != Some(requested_personality) + { + mismatch_details.push(format!( + "personality requested={requested_personality:?} active={:?}", + config_snapshot.personality + )); + } + + if request.config.is_some() { + mismatch_details + .push("config overrides were provided and ignored while running".to_string()); + } + if request.base_instructions.is_some() { + mismatch_details + .push("baseInstructions override was provided and ignored while running".to_string()); + } + if request.developer_instructions.is_some() { + mismatch_details.push( + "developerInstructions override was provided and ignored while running".to_string(), + ); + } + if request.persist_extended_history { + mismatch_details.push( + "persistExtendedHistory override was provided and ignored while running".to_string(), + ); + } + + mismatch_details +} + fn skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_paths: &std::collections::HashSet, diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs index cb1f61c362..d40d3fc242 100644 --- a/codex-rs/app-server/src/fuzzy_file_search.rs +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -6,6 +6,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use codex_app_server_protocol::FuzzyFileSearchResult; +use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification; use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification; use codex_app_server_protocol::ServerNotification; use codex_file_search as file_search; @@ -195,6 +196,20 @@ impl SessionReporterImpl { outgoing.send_server_notification(notification).await; }); } + + fn send_complete(&self) { + if self.shared.canceled.load(Ordering::Relaxed) { + return; + } + let session_id = self.shared.session_id.clone(); + let outgoing = self.shared.outgoing.clone(); + self.shared.runtime.spawn(async move { + let notification = ServerNotification::FuzzyFileSearchSessionCompleted( + FuzzyFileSearchSessionCompletedNotification { session_id }, + ); + outgoing.send_server_notification(notification).await; + }); + } } impl file_search::SessionReporter for SessionReporterImpl { @@ -202,7 +217,9 @@ impl file_search::SessionReporter for SessionReporterImpl { self.send_snapshot(snapshot); } - fn on_complete(&self) {} + fn on_complete(&self) { + self.send_complete(); + } } fn collect_files(snapshot: &file_search::FileSearchSnapshot) -> Vec { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 350b86f92a..1594c66229 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -3,17 +3,19 @@ use std::sync::Arc; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ThreadManager; -use codex_core::config::Config; use codex_core::models_manager::manager::RefreshStrategy; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub async fn supported_models(thread_manager: Arc, config: &Config) -> Vec { +pub async fn supported_models( + thread_manager: Arc, + include_hidden: bool, +) -> Vec { thread_manager - .list_models(config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await .into_iter() - .filter(|preset| preset.show_in_picker) + .filter(|preset| include_hidden || preset.show_in_picker) .map(model_from_preset) .collect() } @@ -25,6 +27,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { upgrade: preset.upgrade.map(|upgrade| upgrade.id), display_name: preset.display_name.to_string(), description: preset.description.to_string(), + hidden: !preset.show_in_picker, supported_reasoning_efforts: reasoning_efforts_from_preset( preset.supported_reasoning_efforts, ), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 2d8b748c90..810182db0a 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -399,6 +399,8 @@ mod tests { use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::LoginChatGptCompleteNotification; + use codex_app_server_protocol::ModelRerouteReason; + use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_protocol::ThreadId; @@ -546,6 +548,34 @@ mod tests { ); } + #[test] + fn verify_model_rerouted_notification_serialization() { + let notification = ServerNotification::ModelRerouted(ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5.3-codex".to_string(), + to_model: "gpt-5.2".to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "model/rerouted", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "fromModel": "gpt-5.3-codex", + "toModel": "gpt-5.2", + "reason": "highRiskCyberActivity", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index 09471b4a69..bffb35aa02 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -13,7 +13,7 @@ pub fn write_mock_responses_config_toml( compact_prompt: &str, ) -> std::io::Result<()> { // Phase 1: build the features block for config.toml. - let mut features = BTreeMap::from([(Feature::RemoteModels, false)]); + let mut features = BTreeMap::new(); for (feature, enabled) in feature_flags { features.insert(*feature, *enabled); } diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 242a71f98d..5095bf0f3d 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -41,6 +41,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, } } diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index ffa4fa2cca..b6932560f0 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -1,6 +1,7 @@ use anyhow::Result; use anyhow::anyhow; use app_test_support::McpProcess; +use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification; use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -13,6 +14,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const SHORT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); const STOP_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(250); const SESSION_UPDATED_METHOD: &str = "fuzzyFileSearch/sessionUpdated"; +const SESSION_COMPLETED_METHOD: &str = "fuzzyFileSearch/sessionCompleted"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FileExpectation { @@ -60,6 +62,29 @@ async fn wait_for_session_updated( ); } +async fn wait_for_session_completed( + mcp: &mut McpProcess, + session_id: &str, +) -> Result { + for _ in 0..20 { + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message(SESSION_COMPLETED_METHOD), + ) + .await??; + let params = notification + .params + .ok_or_else(|| anyhow!("missing notification params"))?; + let payload = + serde_json::from_value::(params)?; + if payload.session_id == session_id { + return Ok(payload); + } + } + + anyhow::bail!("did not receive expected session completion for sessionId={session_id}"); +} + async fn assert_update_request_fails_for_missing_session( mcp: &mut McpProcess, session_id: &str, @@ -271,12 +296,41 @@ async fn test_fuzzy_file_search_session_streams_updates() -> Result<()> { assert_eq!(payload.files.len(), 1); assert_eq!(payload.files[0].root, root_path); assert_eq!(payload.files[0].path, "alpha.txt"); + let completed = wait_for_session_completed(&mut mcp, session_id).await?; + assert_eq!(completed.session_id, session_id); mcp.stop_fuzzy_file_search_session(session_id).await?; Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_no_updates_after_complete_until_query_edited() -> Result<()> +{ + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-complete-invariant"; + mcp.start_fuzzy_file_search_session(session_id, vec![root_path]) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alp") + .await?; + wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?; + wait_for_session_completed(&mut mcp, session_id).await?; + assert_no_session_updates_for(&mut mcp, session_id, STOP_GRACE_PERIOD, SHORT_READ_TIMEOUT) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alpha") + .await?; + wait_for_session_updated(&mut mcp, session_id, "alpha", FileExpectation::NonEmpty).await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fuzzy_file_search_session_update_before_start_errors() -> Result<()> { let codex_home = TempDir::new()?; @@ -345,6 +399,7 @@ async fn test_fuzzy_file_search_session_multiple_query_updates_work() -> Result< alp_payload.files.iter().all(|file| file.root == root_path), true ); + wait_for_session_completed(&mut mcp, session_id).await?; mcp.update_fuzzy_file_search_session(session_id, "zzzz") .await?; @@ -352,6 +407,7 @@ async fn test_fuzzy_file_search_session_multiple_query_updates_work() -> Result< wait_for_session_updated(&mut mcp, session_id, "zzzz", FileExpectation::Any).await?; assert_eq!(zzzz_payload.query, "zzzz"); assert_eq!(zzzz_payload.files.is_empty(), true); + wait_for_session_completed(&mut mcp, session_id).await?; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index d5e0154121..25dad0e0ae 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -477,7 +477,6 @@ fn assert_permissions_message(item: &ResponseItem) { &SandboxPolicy::DangerFullAccess, AskForApproval::Never, &Policy::empty(), - false, &PathBuf::from("/tmp"), ) .into_text(); diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 079eba4794..1fd3f5a447 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::time::Duration; @@ -16,8 +17,12 @@ use axum::http::HeaderMap; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; use axum::routing::get; +use codex_app_server_protocol::AppBranding; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::AppMetadata; +use codex_app_server_protocol::AppReview; +use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::JSONRPCError; @@ -85,6 +90,9 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -184,6 +192,9 @@ async fn list_apps_reports_is_enabled_from_config() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -249,6 +260,39 @@ enabled = false #[tokio::test] async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> { + let alpha_branding = Some(AppBranding { + category: Some("PRODUCTIVITY".to_string()), + developer: Some("Acme".to_string()), + website: Some("https://acme.example".to_string()), + privacy_policy: Some("https://acme.example/privacy".to_string()), + terms_of_service: Some("https://acme.example/terms".to_string()), + is_discoverable_app: true, + }); + let alpha_app_metadata = Some(AppMetadata { + review: Some(AppReview { + status: "APPROVED".to_string(), + }), + categories: Some(vec!["PRODUCTIVITY".to_string()]), + sub_categories: Some(vec!["WRITING".to_string()]), + seo_description: Some("Alpha connector".to_string()), + screenshots: Some(vec![AppScreenshot { + url: Some("https://example.com/alpha-screenshot.png".to_string()), + file_id: Some("file_123".to_string()), + user_prompt: "Summarize this draft".to_string(), + }]), + developer: Some("Acme".to_string()), + version: Some("1.2.3".to_string()), + version_id: Some("version_123".to_string()), + version_notes: Some("Fixes and improvements".to_string()), + first_party_type: Some("internal".to_string()), + first_party_requires_install: Some(true), + show_in_composer_when_unlinked: Some(true), + }); + let alpha_labels = Some(HashMap::from([ + ("feature".to_string(), "beta".to_string()), + ("source".to_string(), "directory".to_string()), + ])); + let connectors = vec![ AppInfo { id: "alpha".to_string(), @@ -257,6 +301,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: alpha_branding.clone(), + app_metadata: alpha_app_metadata.clone(), + labels: alpha_labels.clone(), install_url: None, is_accessible: false, is_enabled: true, @@ -268,6 +315,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -313,6 +363,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, @@ -329,6 +382,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, @@ -340,6 +396,9 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: alpha_branding, + app_metadata: alpha_app_metadata, + labels: alpha_labels, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -377,6 +436,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -388,6 +450,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -437,6 +502,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -448,6 +516,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: false, is_enabled: true, @@ -463,6 +534,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, @@ -474,6 +548,9 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { logo_url: Some("https://example.com/alpha.png".to_string()), logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -506,6 +583,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -517,6 +597,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -571,6 +654,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, @@ -611,6 +697,9 @@ async fn list_apps_paginates_results() -> Result<()> { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -632,6 +721,9 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -733,6 +825,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -744,6 +839,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -790,6 +888,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, @@ -807,6 +908,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, @@ -818,6 +922,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -844,6 +951,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -870,6 +980,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, @@ -881,6 +994,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: false, is_enabled: true, @@ -895,6 +1011,9 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 48622acddb..1eacc2a84e 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -15,6 +15,7 @@ mod plan_item; mod rate_limits; mod request_user_input; mod review; +mod safety_check_downgrade; mod skills_list; mod thread_archive; mod thread_fork; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index c2431d24d0..ebcbdf99e7 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -33,6 +33,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(100), cursor: None, + include_hidden: None, }) .await?; @@ -54,6 +55,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { upgrade: None, display_name: "gpt-5.2-codex".to_string(), description: "Latest frontier agentic coding model.".to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Low, @@ -84,6 +86,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { upgrade: Some("gpt-5.2-codex".to_string()), display_name: "gpt-5.1-codex-max".to_string(), description: "Codex-optimized flagship for deep and fast reasoning.".to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Low, @@ -114,6 +117,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { upgrade: Some("gpt-5.2-codex".to_string()), display_name: "gpt-5.1-codex-mini".to_string(), description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Medium, @@ -138,6 +142,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { description: "Latest frontier model with improvements across knowledge, reasoning and coding" .to_string(), + hidden: false, supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Low, @@ -173,6 +178,38 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { Ok(()) } +#[tokio::test] +async fn list_models_includes_hidden_models() -> Result<()> { + let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: Some(100), + cursor: None, + include_hidden: Some(true), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let ModelListResponse { + data: items, + next_cursor, + } = to_response::(response)?; + + assert!(items.iter().any(|item| item.hidden)); + assert!(next_cursor.is_none()); + Ok(()) +} + #[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; @@ -185,6 +222,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: None, + include_hidden: None, }) .await?; @@ -207,6 +245,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: Some(next_cursor.clone()), + include_hidden: None, }) .await?; @@ -229,6 +268,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: Some(third_cursor.clone()), + include_hidden: None, }) .await?; @@ -251,6 +291,7 @@ async fn list_models_pagination_works() -> Result<()> { .send_list_models_request(ModelListParams { limit: Some(1), cursor: Some(fourth_cursor.clone()), + include_hidden: None, }) .await?; @@ -283,6 +324,7 @@ async fn list_models_rejects_invalid_cursor() -> Result<()> { .send_list_models_request(ModelListParams { limit: None, cursor: Some("invalid".to_string()), + include_hidden: None, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index d138954ac4..881bbb54c8 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -215,10 +215,7 @@ async fn collect_turn_notifications( } fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - let features = BTreeMap::from([ - (Feature::RemoteModels, false), - (Feature::CollaborationModes, true), - ]); + let features = BTreeMap::from([(Feature::CollaborationModes, true)]); let feature_entries = features .into_iter() .map(|(feature, enabled)| { diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 05397d485d..219812d6b1 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -437,7 +437,6 @@ sandbox_mode = "read-only" model_provider = "mock_provider" [features] -remote_models = false shell_snapshot = false [model_providers.mock_provider] diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs new file mode 100644 index 0000000000..53acd492b6 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -0,0 +1,244 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ModelRerouteReason; +use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const REQUESTED_MODEL: &str = "gpt-5.1-codex-max"; +const SERVER_MODEL: &str = "gpt-5.2-codex"; + +#[tokio::test] +async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body).insert_header("OpenAI-Model", SERVER_MODEL); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger safeguard".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; + assert_eq!( + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + serde_json::json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": SERVER_MODEL, + } + }), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body).insert_header("OpenAI-Model", REQUESTED_MODEL); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger response model check".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; + assert_eq!( + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } + ); + + Ok(()) +} + +async fn collect_turn_notifications_and_validate_no_warning_item( + mcp: &mut McpProcess, +) -> Result { + let mut rerouted = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "model/rerouted" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("model/rerouted notifications must include params") + })?; + let payload: ModelReroutedNotification = serde_json::from_value(params)?; + rerouted = Some(payload); + } + "item/started" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/started notifications must include params") + })?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "item/completed" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/completed notifications must include params") + })?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "turn/completed" => { + return rerouted.ok_or_else(|| { + anyhow::anyhow!("expected model/rerouted notification before turn/completed") + }); + } + _ => {} + } + } +} + +fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { + let ThreadItem::UserMessage { content, .. } = item else { + return None; + }; + + content.iter().find_map(|input| match input { + UserInput::Text { text, .. } if text.starts_with("Warning: ") => Some(text.as_str()), + _ => None, + }) +} + +fn is_warning_user_message_item(item: &ThreadItem) -> bool { + warning_text_from_item(item).is_some() +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "{REQUESTED_MODEL}" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +remote_models = false +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index a92d24ed08..3976d3eea6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -209,18 +209,412 @@ async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() - Ok(()) } +#[tokio::test] +async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "respond with docs".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = secondary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let _first_response_mock = responses::mount_sse_once(&server, first_body).await; + let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + history: Some(vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "history override".to_string(), + }], + end_turn: None, + phase: None, + }]), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + assert!( + resume_err.error.message.contains("cannot resume thread") + && resume_err.error.message.contains("with history") + && resume_err.error.message.contains("running"), + "unexpected resume error: {}", + resume_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let _first_response_mock = responses::mount_sse_once(&server, first_body).await; + let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + assert!( + resume_err.error.message.contains("mismatched path"), + "unexpected resume error: {}", + resume_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let _response_mock = + responses::mount_sse_sequence(&server, vec![first_body, second_body]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + model: Some("not-the-running-model".to_string()), + cwd: Some("/tmp".to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { model, .. } = to_response::(resume_resp)?; + assert_eq!(model, "gpt-5.1-codex-max"); + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; + create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let RestartedThreadFixture { + mut mcp, + thread_id, + rollout_file_path, + } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; + let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z"; + set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?; + let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + let expected_updated_at = chrono::DateTime::parse_from_rfc3339(expected_updated_at_rfc3339)? + .with_timezone(&Utc) + .timestamp(); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: rollout.conversation_id.clone(), + thread_id, model: Some("mock-model".to_string()), ..Default::default() }) @@ -230,16 +624,19 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + let ThreadResumeResponse { + thread: resumed_thread, + .. + } = to_response::(resume_resp)?; - assert_eq!(thread.updated_at, rollout.expected_updated_at); + assert_eq!(resumed_thread.updated_at, expected_updated_at); - let after_resume_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; - assert_eq!(after_resume_modified, rollout.before_modified); + let after_resume_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + assert_eq!(after_resume_modified, before_modified); let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id: rollout.conversation_id, + thread_id: resumed_thread.id, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -258,8 +655,8 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re ) .await??; - let after_turn_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; - assert!(after_turn_modified > rollout.before_modified); + let after_turn_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + assert!(after_turn_modified > before_modified); Ok(()) } @@ -374,22 +771,9 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // Start a thread. - let start_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("gpt-5.1-codex-max".to_string()), - ..Default::default() - }) - .await?; - let start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(start_id)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let RestartedThreadFixture { + mut mcp, thread_id, .. + } = start_materialized_thread_and_restart(codex_home.path(), "seed history").await?; let history_text = "Hello from history"; let history = vec![ResponseItem::Message { @@ -405,7 +789,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { // Resume with explicit history and override the model. let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: thread.id, + thread_id, history: Some(history), model: Some("mock-model".to_string()), model_provider: Some("mock_provider".to_string()), @@ -429,6 +813,70 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { Ok(()) } +struct RestartedThreadFixture { + mcp: McpProcess, + thread_id: String, + rollout_file_path: PathBuf, +} + +async fn start_materialized_thread_and_restart( + codex_home: &Path, + seed_text: &str, +) -> Result { + let mut first_mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; + + let start_id = first_mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.1-codex-max".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let materialize_turn_id = first_mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: seed_text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(materialize_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let thread_id = thread.id; + let rollout_file_path = thread + .path + .ok_or_else(|| anyhow::anyhow!("thread path missing from thread/start response"))?; + + drop(first_mcp); + + let mut second_mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; + + Ok(RestartedThreadFixture { + mcp: second_mcp, + thread_id, + rollout_file_path, + }) +} + #[tokio::test] async fn thread_resume_accepts_personality_override() -> Result<()> { skip_if_no_network!(Ok(())); @@ -449,10 +897,10 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; - let start_id = mcp + let start_id = primary .send_thread_start_request(ThreadStartParams { model: Some("gpt-5.2-codex".to_string()), ..Default::default() @@ -460,12 +908,12 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; let start_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + primary.read_stream_until_response_message(RequestId::Integer(start_id)), ) .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - let materialize_id = mcp + let materialize_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { @@ -477,16 +925,19 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(materialize_id)), + primary.read_stream_until_response_message(RequestId::Integer(materialize_id)), ) .await??; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), + primary.read_stream_until_notification_message("turn/completed"), ) .await??; - let resume_id = mcp + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let resume_id = secondary .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id, model: Some("gpt-5.2-codex".to_string()), @@ -496,12 +947,12 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; let resume_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; let resume: ThreadResumeResponse = to_response::(resume_resp)?; - let turn_id = mcp + let turn_id = secondary .send_turn_start_request(TurnStartParams { thread_id: resume.thread.id, input: vec![UserInput::Text { @@ -513,13 +964,13 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .await?; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + secondary.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), + secondary.read_stream_until_notification_message("turn/completed"), ) .await??; @@ -557,7 +1008,6 @@ sandbox_mode = "read-only" model_provider = "mock_provider" [features] -remote_models = false personality = true [model_providers.mock_provider] @@ -587,7 +1037,6 @@ sandbox_mode = "read-only" model_provider = "mock_provider" [features] -remote_models = false personality = true [model_providers.mock_provider] diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 97cb14f445..2eecfc5590 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1871,7 +1871,7 @@ fn create_config_toml_with_sandbox( feature_flags: &BTreeMap, sandbox_mode: &str, ) -> std::io::Result<()> { - let mut features = BTreeMap::from([(Feature::RemoteModels, false)]); + let mut features = BTreeMap::new(); for (feature, enabled) in feature_flags { features.insert(*feature, *enabled); } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index bafb0273a4..8955fbf84a 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -14,7 +14,9 @@ use crate::chatgpt_client::chatgpt_get_request_with_timeout; use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; +use codex_core::connectors::AppBranding; pub use codex_core::connectors::AppInfo; +use codex_core::connectors::AppMetadata; use codex_core::connectors::CONNECTORS_CACHE_TTL; pub use codex_core::connectors::connector_display_label; use codex_core::connectors::connector_install_url; @@ -36,6 +38,10 @@ struct DirectoryApp { id: String, name: String, description: Option, + #[serde(alias = "appMetadata")] + app_metadata: Option, + branding: Option, + labels: Option>, #[serde(alias = "logoUrl")] logo_url: Option, #[serde(alias = "logoUrlDark")] @@ -269,6 +275,9 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { id: _, name, description, + app_metadata, + branding, + labels, logo_url, logo_url_dark, distribution_channel, @@ -302,6 +311,108 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { if existing.distribution_channel.is_none() && distribution_channel.is_some() { existing.distribution_channel = distribution_channel; } + + if let Some(incoming_branding) = branding { + if let Some(existing_branding) = existing.branding.as_mut() { + if existing_branding.category.is_none() && incoming_branding.category.is_some() { + existing_branding.category = incoming_branding.category; + } + if existing_branding.developer.is_none() && incoming_branding.developer.is_some() { + existing_branding.developer = incoming_branding.developer; + } + if existing_branding.website.is_none() && incoming_branding.website.is_some() { + existing_branding.website = incoming_branding.website; + } + if existing_branding.privacy_policy.is_none() + && incoming_branding.privacy_policy.is_some() + { + existing_branding.privacy_policy = incoming_branding.privacy_policy; + } + if existing_branding.terms_of_service.is_none() + && incoming_branding.terms_of_service.is_some() + { + existing_branding.terms_of_service = incoming_branding.terms_of_service; + } + if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app { + existing_branding.is_discoverable_app = true; + } + } else { + existing.branding = Some(incoming_branding); + } + } + + if let Some(incoming_app_metadata) = app_metadata { + if let Some(existing_app_metadata) = existing.app_metadata.as_mut() { + if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() { + existing_app_metadata.review = incoming_app_metadata.review; + } + if existing_app_metadata.categories.is_none() + && incoming_app_metadata.categories.is_some() + { + existing_app_metadata.categories = incoming_app_metadata.categories; + } + if existing_app_metadata.sub_categories.is_none() + && incoming_app_metadata.sub_categories.is_some() + { + existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories; + } + if existing_app_metadata.seo_description.is_none() + && incoming_app_metadata.seo_description.is_some() + { + existing_app_metadata.seo_description = incoming_app_metadata.seo_description; + } + if existing_app_metadata.screenshots.is_none() + && incoming_app_metadata.screenshots.is_some() + { + existing_app_metadata.screenshots = incoming_app_metadata.screenshots; + } + if existing_app_metadata.developer.is_none() + && incoming_app_metadata.developer.is_some() + { + existing_app_metadata.developer = incoming_app_metadata.developer; + } + if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() { + existing_app_metadata.version = incoming_app_metadata.version; + } + if existing_app_metadata.version_id.is_none() + && incoming_app_metadata.version_id.is_some() + { + existing_app_metadata.version_id = incoming_app_metadata.version_id; + } + if existing_app_metadata.version_notes.is_none() + && incoming_app_metadata.version_notes.is_some() + { + existing_app_metadata.version_notes = incoming_app_metadata.version_notes; + } + if existing_app_metadata.first_party_type.is_none() + && incoming_app_metadata.first_party_type.is_some() + { + existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type; + } + if existing_app_metadata.first_party_requires_install.is_none() + && incoming_app_metadata.first_party_requires_install.is_some() + { + existing_app_metadata.first_party_requires_install = + incoming_app_metadata.first_party_requires_install; + } + if existing_app_metadata + .show_in_composer_when_unlinked + .is_none() + && incoming_app_metadata + .show_in_composer_when_unlinked + .is_some() + { + existing_app_metadata.show_in_composer_when_unlinked = + incoming_app_metadata.show_in_composer_when_unlinked; + } + } else { + existing.app_metadata = Some(incoming_app_metadata); + } + } + + if existing.labels.is_none() && labels.is_some() { + existing.labels = labels; + } } fn is_hidden_directory_app(app: &DirectoryApp) -> bool { @@ -316,6 +427,9 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { logo_url: app.logo_url, logo_url_dark: app.logo_url_dark, distribution_channel: app.distribution_channel, + branding: app.branding, + app_metadata: app.app_metadata, + labels: app.labels, install_url: None, is_accessible: false, is_enabled: true, @@ -342,6 +456,8 @@ const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", "connector_68de829bf7648191acd70a907364c67c", + "connector_68e004f14af881919eb50893d3d9f523", + "connector_69272cb413a081919685ec3c88d1744e", ]; const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; @@ -375,6 +491,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: false, is_enabled: true, @@ -429,6 +548,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some(connector_install_url(id, id)), is_accessible, is_enabled: true, diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c19d58189f..a65fa486e4 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -213,12 +213,19 @@ async fn run_command_under_sandbox( #[cfg(not(target_os = "macos"))] let _ = log_denials; + let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + // This proxy should only live for the lifetime of the child process. let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( - spec.start_proxy() - .await - .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, + spec.start_proxy( + config.permissions.sandbox_policy.get(), + None, + None, + managed_network_requirements_enabled, + ) + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, ), None => None, }; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 494f8d8935..cae322c77e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -93,10 +93,10 @@ enum Subcommand { /// Remove stored authentication credentials. Logout(LogoutCommand), - /// [experimental] Run Codex as an MCP server and manage MCP servers. + /// Manage external MCP servers for Codex. Mcp(McpCli), - /// [experimental] Run the Codex MCP server (stdio transport). + /// Start Codex as an MCP server (stdio). McpServer, /// [experimental] Run the app server or related tooling. diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index af08b6c977..3ef96c185b 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -56,6 +56,9 @@ pub enum ResponseEvent { Created, OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), + /// Emitted when the server includes `OpenAI-Model` on the stream response. + /// This can differ from the requested model when backend safety routing applies. + ServerModel(String), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. diff --git a/codex-rs/codex-api/src/endpoint/aggregate.rs b/codex-rs/codex-api/src/endpoint/aggregate.rs index a91eec90a3..6d8e785c82 100644 --- a/codex-rs/codex-api/src/endpoint/aggregate.rs +++ b/codex-rs/codex-api/src/endpoint/aggregate.rs @@ -63,6 +63,9 @@ impl Stream for AggregatedStream { Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))) => { return Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))); } + Poll::Ready(Some(Ok(ResponseEvent::ServerModel(model)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::ServerModel(model)))); + } Poll::Ready(Some(Ok(ResponseEvent::Completed { response_id, token_usage, diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 6ebf5ab65e..aa559e9836 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -163,6 +163,7 @@ impl Drop for WsStream { const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; const X_MODELS_ETAG_HEADER: &str = "x-models-etag"; const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; +const OPENAI_MODEL_HEADER: &str = "openai-model"; pub struct ResponsesWebsocketConnection { stream: Arc>>, @@ -170,6 +171,7 @@ pub struct ResponsesWebsocketConnection { idle_timeout: Duration, server_reasoning_included: bool, models_etag: Option, + server_model: Option, telemetry: Option>, } @@ -179,6 +181,7 @@ impl ResponsesWebsocketConnection { idle_timeout: Duration, server_reasoning_included: bool, models_etag: Option, + server_model: Option, telemetry: Option>, ) -> Self { Self { @@ -186,6 +189,7 @@ impl ResponsesWebsocketConnection { idle_timeout, server_reasoning_included, models_etag, + server_model, telemetry, } } @@ -204,12 +208,16 @@ impl ResponsesWebsocketConnection { let idle_timeout = self.idle_timeout; let server_reasoning_included = self.server_reasoning_included; let models_etag = self.models_etag.clone(); + let server_model = self.server_model.clone(); let telemetry = self.telemetry.clone(); let request_body = serde_json::to_value(&request).map_err(|err| { ApiError::Stream(format!("failed to encode websocket request: {err}")) })?; tokio::spawn(async move { + if let Some(model) = server_model { + let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await; + } if let Some(etag) = models_etag { let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await; } @@ -273,13 +281,14 @@ impl ResponsesWebsocketClient { merge_request_headers(&self.provider.headers, extra_headers, default_headers); add_auth_headers_to_header_map(&self.auth, &mut headers); - let (stream, server_reasoning_included, models_etag) = + let (stream, server_reasoning_included, models_etag, server_model) = connect_websocket(ws_url, headers, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, self.provider.stream_idle_timeout, server_reasoning_included, models_etag, + server_model, telemetry, )) } @@ -304,7 +313,7 @@ async fn connect_websocket( url: Url, headers: HeaderMap, turn_state: Option>>, -) -> Result<(WsStream, bool, Option), ApiError> { +) -> Result<(WsStream, bool, Option, Option), ApiError> { ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); @@ -341,6 +350,11 @@ async fn connect_websocket( .get(X_MODELS_ETAG_HEADER) .and_then(|value| value.to_str().ok()) .map(ToString::to_string); + let server_model = response + .headers() + .get(OPENAI_MODEL_HEADER) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); if let Some(turn_state) = turn_state && let Some(header_value) = response .headers() @@ -349,7 +363,12 @@ async fn connect_websocket( { let _ = turn_state.set(header_value.to_string()); } - Ok((WsStream::new(stream), reasoning_included, models_etag)) + Ok(( + WsStream::new(stream), + reasoning_included, + models_etag, + server_model, + )) } fn websocket_config() -> WebSocketConfig { @@ -469,6 +488,7 @@ async fn run_websocket_response_stream( idle_timeout: Duration, telemetry: Option>, ) -> Result<(), ApiError> { + let mut last_server_model: Option = None; let request_text = match serde_json::to_string(&request_body) { Ok(text) => text, Err(err) => { @@ -536,6 +556,14 @@ async fn run_websocket_response_stream( } continue; } + if let Some(model) = event.response_model() + && last_server_model.as_deref() != Some(model.as_str()) + { + let _ = tx_event + .send(Ok(ResponseEvent::ServerModel(model.clone()))) + .await; + last_server_model = Some(model); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 11e4f9de34..75c79ba5c1 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -26,6 +26,7 @@ use tracing::debug; use tracing::trace; const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; +const OPENAI_MODEL_HEADER: &str = "openai-model"; /// Streams SSE events from an on-disk fixture for tests. pub fn stream_from_fixture( @@ -60,6 +61,11 @@ pub fn spawn_response_stream( .get("X-Models-Etag") .and_then(|v| v.to_str().ok()) .map(ToString::to_string); + let server_model = stream_response + .headers + .get(OPENAI_MODEL_HEADER) + .and_then(|v| v.to_str().ok()) + .map(ToString::to_string); let reasoning_included = stream_response .headers .get(X_REASONING_INCLUDED_HEADER) @@ -74,6 +80,9 @@ pub fn spawn_response_stream( } let (tx_event, rx_event) = mpsc::channel::>(1600); tokio::spawn(async move { + if let Some(model) = server_model { + let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await; + } for snapshot in rate_limit_snapshots { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; } @@ -169,6 +178,41 @@ impl ResponsesStreamEvent { pub fn kind(&self) -> &str { &self.kind } + + pub fn response_model(&self) -> Option { + self.response.as_ref().and_then(extract_server_model) + } +} + +fn extract_server_model(value: &Value) -> Option { + value + .get("model") + .and_then(json_value_as_string) + .or_else(|| { + value + .get("headers") + .and_then(header_openai_model_value_from_json) + }) +} + +fn header_openai_model_value_from_json(value: &Value) -> Option { + let headers = value.as_object()?; + headers.iter().find_map(|(name, value)| { + if name.eq_ignore_ascii_case("openai-model") || name.eq_ignore_ascii_case("x-openai-model") + { + json_value_as_string(value) + } else { + None + } + }) +} + +fn json_value_as_string(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Array(items) => items.first().and_then(json_value_as_string), + _ => None, + } } #[derive(Debug)] @@ -339,6 +383,7 @@ pub async fn process_sse( ) { let mut stream = stream.eventsource(); let mut response_error: Option = None; + let mut last_server_model: Option = None; loop { let start = Instant::now(); @@ -378,6 +423,19 @@ pub async fn process_sse( } }; + if let Some(model) = event.response_model() + && last_server_model.as_deref() != Some(model.as_str()) + { + if tx_event + .send(Ok(ResponseEvent::ServerModel(model.clone()))) + .await + .is_err() + { + return; + } + last_server_model = Some(model); + } + match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); @@ -456,9 +514,13 @@ mod tests { use super::*; use assert_matches::assert_matches; use bytes::Bytes; + use codex_client::StreamResponse; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use futures::stream; + use http::HeaderMap; + use http::HeaderValue; + use http::StatusCode; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::mpsc; @@ -870,6 +932,149 @@ mod tests { } } + #[tokio::test] + async fn spawn_response_stream_emits_server_model_header() { + let mut headers = HeaderMap::new(); + headers.insert( + OPENAI_MODEL_HEADER, + HeaderValue::from_static(CYBER_RESTRICTED_MODEL_FOR_TESTS), + ); + let bytes = stream::iter(Vec::>::new()); + let stream_response = StreamResponse { + status: StatusCode::OK, + headers, + bytes: Box::pin(bytes), + }; + + let mut stream = spawn_response_stream(stream_response, idle_timeout(), None, None); + let event = stream + .rx_event + .recv() + .await + .expect("expected server model event") + .expect("expected ok event"); + + match event { + ResponseEvent::ServerModel(model) => { + assert_eq!(model, CYBER_RESTRICTED_MODEL_FOR_TESTS); + } + other => panic!("expected server model event, got {other:?}"), + } + } + + #[tokio::test] + async fn process_sse_emits_server_model_from_response_payload() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1", + "model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + }), + ]) + .await; + + assert_eq!(events.len(), 3); + assert_matches!( + &events[0], + ResponseEvent::ServerModel(model) if model == CYBER_RESTRICTED_MODEL_FOR_TESTS + ); + assert_matches!(&events[1], ResponseEvent::Created); + assert_matches!( + &events[2], + ResponseEvent::Completed { + response_id, + token_usage: None, + can_append: false + } if response_id == "resp-1" + ); + } + + #[tokio::test] + async fn process_sse_emits_server_model_from_response_headers_payload() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "headers": { + "OpenAI-Model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_eq!(events.len(), 3); + assert_matches!( + &events[0], + ResponseEvent::ServerModel(model) if model == CYBER_RESTRICTED_MODEL_FOR_TESTS + ); + assert_matches!(&events[1], ResponseEvent::Created); + assert_matches!( + &events[2], + ResponseEvent::Completed { + response_id, + token_usage: None, + can_append: false + } if response_id == "resp-1" + ); + } + + #[tokio::test] + async fn process_sse_emits_server_model_again_when_response_model_changes() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": "gpt-5.2-codex" + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1", + "model": "gpt-5.3-codex" + } + }), + ]) + .await; + + assert_eq!(events.len(), 4); + assert_matches!( + &events[0], + ResponseEvent::ServerModel(model) if model == "gpt-5.2-codex" + ); + assert_matches!(&events[1], ResponseEvent::Created); + assert_matches!( + &events[2], + ResponseEvent::ServerModel(model) if model == "gpt-5.3-codex" + ); + assert_matches!( + &events[3], + ResponseEvent::Completed { + response_id, + token_usage: None, + can_append: false + } if response_id == "resp-1" + ); + } + #[test] fn test_try_parse_retry_after() { let err = Error { @@ -909,4 +1114,6 @@ mod tests { let delay = try_parse_retry_after(&err); assert_eq!(delay, Some(Duration::from_secs(35))); } + + const CYBER_RESTRICTED_MODEL_FOR_TESTS: &str = "gpt-5.3-codex"; } diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index b33f8b308b..9f7e380665 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -89,6 +89,7 @@ async fn models_client_hits_models_endpoint() { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, }], }; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 1b3f62c5aa..64b7330c3a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -19,7 +19,7 @@ workspace = true [dependencies] anyhow = { workspace = true } -arc-swap = "1.8.0" +arc-swap = "1.8.2" async-channel = { workspace = true } async-trait = { workspace = true } askama = { workspace = true } @@ -152,6 +152,7 @@ codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } +insta = { workspace = true } maplit = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f95958830e..9af40532a8 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -6,8 +6,28 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "AgentsToml": { + "AgentRoleToml": { "additionalProperties": false, + "properties": { + "config_file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path to a role-specific config layer." + }, + "description": { + "description": "Human-facing role documentation used in spawn tool guidance.", + "type": "string" + } + }, + "type": "object" + }, + "AgentsToml": { + "additionalProperties": { + "$ref": "#/definitions/AgentRoleToml" + }, "properties": { "max_threads": { "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", @@ -299,6 +319,9 @@ "child_agents_md": { "type": "boolean" }, + "codex_git_commit": { + "type": "boolean" + }, "collab": { "type": "boolean" }, @@ -338,6 +361,9 @@ "memory_tool": { "type": "boolean" }, + "multi_agent": { + "type": "boolean" + }, "personality": { "type": "boolean" }, @@ -1390,6 +1416,10 @@ "default": null, "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, + "commit_attribution": { + "description": "Optional commit attribution text for commit message co-author trailers.\n\nSet to an empty string to disable automatic commit attribution.", + "type": "string" + }, "compact_prompt": { "description": "Compact prompt used for history compaction.", "type": "string" @@ -1429,6 +1459,9 @@ "child_agents_md": { "type": "boolean" }, + "codex_git_commit": { + "type": "boolean" + }, "collab": { "type": "boolean" }, @@ -1468,6 +1501,9 @@ "memory_tool": { "type": "boolean" }, + "multi_agent": { + "type": "boolean" + }, "personality": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/builtins/explorer.toml b/codex-rs/core/src/agent/builtins/explorer.toml new file mode 100644 index 0000000000..0b6def0323 --- /dev/null +++ b/codex-rs/core/src/agent/builtins/explorer.toml @@ -0,0 +1,2 @@ +model = "gpt-5.1-codex-mini" +model_reasoning_effort = "medium" diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 03652e43e5..d9a8361848 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -8,5 +8,4 @@ pub(crate) use control::AgentControl; pub(crate) use guards::MAX_THREAD_SPAWN_DEPTH; pub(crate) use guards::exceeds_thread_spawn_depth_limit; pub(crate) use guards::next_thread_spawn_depth; -pub(crate) use role::AgentRole; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 1dcf8b679e..97cde2c8a3 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -1,99 +1,168 @@ +use crate::config::AgentRoleConfig; use crate::config::Config; -use crate::protocol::SandboxPolicy; -use codex_protocol::openai_models::ReasoningEffort; -use serde::Deserialize; -use serde::Serialize; +use crate::config::ConfigOverrides; +use crate::config::deserialize_config_toml_with_base; +use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; +use crate::config_loader::resolve_relative_paths_in_config_toml; +use codex_app_server_protocol::ConfigLayerSource; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::LazyLock; +use toml::Value as TomlValue; -/// Base instructions for the orchestrator role. -const ORCHESTRATOR_PROMPT: &str = include_str!("../../templates/agents/orchestrator.md"); -/// Default model override used. -// TODO(jif) update when we have something smarter. -const EXPLORER_MODEL: &str = "gpt-5.1-codex-mini"; +const BUILT_IN_EXPLORER_CONFIG: &str = include_str!("builtins/explorer.toml"); +const DEFAULT_ROLE_NAME: &str = "default"; +const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; -/// Enumerated list of all supported agent roles. -const ALL_ROLES: [AgentRole; 3] = [ - AgentRole::Default, - AgentRole::Explorer, - AgentRole::Worker, - // TODO(jif) add when we have stable prompts + models - // AgentRole::Orchestrator, -]; +/// Applies a role config layer to a mutable config and preserves unspecified keys. +pub(crate) async fn apply_role_to_config( + config: &mut Config, + role_name: Option<&str>, +) -> Result<(), String> { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + let (config_file, is_built_in) = config + .agent_roles + .get(role_name) + .map(|role| (&role.config_file, false)) + .or_else(|| { + built_in::configs() + .get(role_name) + .map(|role| (&role.config_file, true)) + }) + .ok_or_else(|| format!("unknown agent_type '{role_name}'"))?; + let Some(config_file) = config_file.as_ref() else { + return Ok(()); + }; -/// Hard-coded agent role selection used when spawning sub-agents. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentRole { - /// Inherit the parent agent's configuration unchanged. - Default, - /// Coordination-only agent that delegates to workers. - Orchestrator, - /// Task-executing agent with a fixed model override. - Worker, - /// Task-executing agent with a fixed model override. - Explorer, + let (role_config_contents, role_config_base) = if is_built_in { + ( + built_in::config_file_contents(config_file) + .map(str::to_owned) + .ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?, + config.codex_home.as_path(), + ) + } else { + ( + tokio::fs::read_to_string(config_file) + .await + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?, + config_file + .parent() + .ok_or_else(|| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?, + ) + }; + + let role_config_toml: TomlValue = toml::from_str(&role_config_contents) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + deserialize_config_toml_with_base(role_config_toml.clone(), role_config_base) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + let role_layer_toml = resolve_relative_paths_in_config_toml(role_config_toml, role_config_base) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + + let mut layers: Vec = config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .into_iter() + .cloned() + .collect(); + let layer = ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml); + let insertion_index = + layers.partition_point(|existing_layer| existing_layer.name <= layer.name); + layers.insert(insertion_index, layer); + + let config_layer_stack = ConfigLayerStack::new( + layers, + config.config_layer_stack.requirements().clone(), + config.config_layer_stack.requirements_toml().clone(), + ) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + + let merged_toml = config_layer_stack.effective_config(); + let merged_config = deserialize_config_toml_with_base(merged_toml, &config.codex_home) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + let next_config = Config::load_config_with_layer_stack( + merged_config, + ConfigOverrides { + cwd: Some(config.cwd.clone()), + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + js_repl_node_path: config.js_repl_node_path.clone(), + ..Default::default() + }, + config.codex_home.clone(), + config_layer_stack, + ) + .map_err(|_| AGENT_TYPE_UNAVAILABLE_ERROR.to_string())?; + *config = next_config; + + Ok(()) } -/// Immutable profile data that drives per-agent configuration overrides. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct AgentProfile { - /// Optional base instructions override. - pub base_instructions: Option<&'static str>, - /// Optional model override. - pub model: Option<&'static str>, - /// Optional reasoning effort override. - pub reasoning_effort: Option, - /// Whether to force a read-only sandbox policy. - pub read_only: bool, - /// Description to include in the tool specs. - pub description: &'static str, -} +pub(crate) mod spawn_tool_spec { + use super::*; -impl AgentRole { - /// Returns the string values used by JSON schema enums. - pub fn enum_values() -> Vec { - ALL_ROLES - .iter() - .filter_map(|role| { - let description = role.profile().description; - serde_json::to_string(role) - .map(|role| { - let description = if !description.is_empty() { - format!(r#", "description": {description}"#) - } else { - String::new() - }; - format!(r#"{{ "name": {role}{description}}}"#) - }) - .ok() - }) - .collect() + /// Builds the spawn-agent tool description text from built-in and configured roles. + pub(crate) fn build(user_defined_agent_roles: &BTreeMap) -> String { + let built_in_roles = built_in::configs(); + build_from_configs(built_in_roles, user_defined_agent_roles) } - /// Returns the hard-coded profile for this role. - pub fn profile(self) -> AgentProfile { - match self { - AgentRole::Default => AgentProfile::default(), - AgentRole::Orchestrator => AgentProfile { - base_instructions: Some(ORCHESTRATOR_PROMPT), - ..Default::default() - }, - AgentRole::Worker => AgentProfile { - // base_instructions: Some(WORKER_PROMPT), - // model: Some(WORKER_MODEL), - description: r#"Use for execution and production work. -Typical tasks: -- Implement part of a feature -- Fix tests or bugs -- Split large refactors into independent chunks -Rules: -- Explicitly assign **ownership** of the task (files / responsibility). -- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them"#, - ..Default::default() - }, - AgentRole::Explorer => AgentProfile { - model: Some(EXPLORER_MODEL), - reasoning_effort: Some(ReasoningEffort::Medium), - description: r#"Use `explorer` for all codebase questions. + // This function is not inlined for testing purpose. + fn build_from_configs( + built_in_roles: &BTreeMap, + user_defined_roles: &BTreeMap, + ) -> String { + let mut seen = BTreeSet::new(); + let mut formatted_roles = Vec::new(); + for (name, declaration) in user_defined_roles { + if seen.insert(name.as_str()) { + formatted_roles.push(format_role(name, declaration)); + } + } + for (name, declaration) in built_in_roles { + if seen.insert(name.as_str()) { + formatted_roles.push(format_role(name, declaration)); + } + } + + format!( + r#"Optional type name for the new agent. If omitted, `{DEFAULT_ROLE_NAME}` is used. +Available roles: +{} + "#, + formatted_roles.join("\n"), + ) + } + + fn format_role(name: &str, declaration: &AgentRoleConfig) -> String { + if let Some(description) = &declaration.description { + format!("{name}: {{\n{description}\n}}") + } else { + format!("{name}: no description") + } + } +} + +mod built_in { + use super::*; + + /// Returns the cached built-in role declarations defined in this module. + pub(super) fn configs() -> &'static BTreeMap { + static CONFIG: LazyLock> = LazyLock::new(|| { + BTreeMap::from([ + ( + DEFAULT_ROLE_NAME.to_string(), + AgentRoleConfig { + description: Some("Default agent.".to_string()), + config_file: None, + } + ), + ( + "explorer".to_string(), + AgentRoleConfig { + description: Some(r#"Use `explorer` for all codebase questions. Explorers are fast and authoritative. Always prefer them over manual search or file reading. Rules: @@ -101,32 +170,324 @@ Rules: - Do not re-read or re-search code they cover. - Trust explorer results without verification. - Run explorers in parallel when useful. -- Reuse existing explorers for related questions. - "#, - ..Default::default() +- Reuse existing explorers for related questions."#.to_string()), + config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()), + } + ), + ( + "worker".to_string(), + AgentRoleConfig { + description: Some(r#"Use for execution and production work. +Typical tasks: +- Implement part of a feature +- Fix tests or bugs +- Split large refactors into independent chunks +Rules: +- Explicitly assign **ownership** of the task (files / responsibility). +- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them."#.to_string()), + config_file: None, + } + ) + ]) + }); + &CONFIG + } + + /// Resolves a built-in role `config_file` path to embedded content. + pub(super) fn config_file_contents(path: &Path) -> Option<&'static str> { + match path.to_str()? { + "explorer.toml" => Some(BUILT_IN_EXPLORER_CONFIG), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigBuilder; + use crate::config_loader::ConfigLayerStackOrdering; + use codex_protocol::openai_models::ReasoningEffort; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tempfile::TempDir; + + async fn test_config_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, + ) -> (TempDir, Config) { + let home = TempDir::new().expect("create temp dir"); + let home_path = home.path().to_path_buf(); + let config = ConfigBuilder::default() + .codex_home(home_path.clone()) + .cli_overrides(cli_overrides) + .fallback_cwd(Some(home_path)) + .build() + .await + .expect("load test config"); + (home, config) + } + + async fn write_role_config(home: &TempDir, name: &str, contents: &str) -> PathBuf { + let role_path = home.path().join(name); + tokio::fs::write(&role_path, contents) + .await + .expect("write role config"); + role_path + } + + fn session_flags_layer_count(config: &Config) -> usize { + config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .into_iter() + .filter(|layer| layer.name == ConfigLayerSource::SessionFlags) + .count() + } + + #[tokio::test] + async fn apply_role_defaults_to_default_and_leaves_config_unchanged() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before = config.clone(); + + apply_role_to_config(&mut config, None) + .await + .expect("default role should apply"); + + assert_eq!(before, config); + } + + #[tokio::test] + async fn apply_role_returns_error_for_unknown_role() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + + let err = apply_role_to_config(&mut config, Some("missing-role")) + .await + .expect_err("unknown role should fail"); + + assert_eq!(err, "unknown agent_type 'missing-role'"); + } + + #[tokio::test] + async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before_layers = session_flags_layer_count(&config); + + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("explorer role should apply"); + + assert_eq!(config.model.as_deref(), Some("gpt-5.1-codex-mini")); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium)); + assert_eq!(session_flags_layer_count(&config), before_layers + 1); + } + + #[tokio::test] + async fn apply_role_returns_unavailable_for_missing_user_role_file() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(PathBuf::from("/path/does/not/exist.toml")), }, + ); + + let err = apply_role_to_config(&mut config, Some("custom")) + .await + .expect_err("missing role file should fail"); + + assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR); + } + + #[tokio::test] + async fn apply_role_returns_unavailable_for_invalid_user_role_toml() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let role_path = write_role_config(&home, "invalid-role.toml", "model = [").await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + let err = apply_role_to_config(&mut config, Some("custom")) + .await + .expect_err("invalid role file should fail"); + + assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR); + } + + #[tokio::test] + async fn apply_role_preserves_unspecified_keys() { + let (home, mut config) = test_config_with_cli_overrides(vec![( + "model".to_string(), + TomlValue::String("base-model".to_string()), + )]) + .await; + let role_path = write_role_config( + &home, + "effort-only.toml", + "model_reasoning_effort = \"high\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.model.as_deref(), Some("base-model")); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + } + + #[tokio::test] + #[cfg(not(windows))] + async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() { + use codex_protocol::protocol::SandboxPolicy; + let (home, mut config) = test_config_with_cli_overrides(vec![ + ( + "sandbox_mode".to_string(), + TomlValue::String("workspace-write".to_string()), + ), + ( + "sandbox_workspace_write.network_access".to_string(), + TomlValue::Boolean(true), + ), + ]) + .await; + let role_path = write_role_config( + &home, + "sandbox-role.toml", + r#"[sandbox_workspace_write] +writable_roots = ["./sandbox-root"] +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + let role_layer = config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .into_iter() + .rfind(|layer| layer.name == ConfigLayerSource::SessionFlags) + .expect("expected a session flags layer"); + let sandbox_workspace_write = role_layer + .config + .get("sandbox_workspace_write") + .and_then(TomlValue::as_table) + .expect("role layer should include sandbox_workspace_write"); + assert_eq!( + sandbox_workspace_write.contains_key("network_access"), + false + ); + assert_eq!( + sandbox_workspace_write.contains_key("exclude_tmpdir_env_var"), + false + ); + assert_eq!( + sandbox_workspace_write.contains_key("exclude_slash_tmp"), + false + ); + + match &*config.permissions.sandbox_policy { + SandboxPolicy::WorkspaceWrite { network_access, .. } => { + assert_eq!(*network_access, true); + } + other => panic!("expected workspace-write sandbox policy, got {other:?}"), } } - /// Applies this role's profile onto the provided config. - pub fn apply_to_config(self, config: &mut Config) -> Result<(), String> { - let profile = self.profile(); - if let Some(base_instructions) = profile.base_instructions { - config.base_instructions = Some(base_instructions.to_string()); - } - if let Some(model) = profile.model { - config.model = Some(model.to_string()); - } - if let Some(reasoning_effort) = profile.reasoning_effort { - config.model_reasoning_effort = Some(reasoning_effort) - } - if profile.read_only { - config - .permissions - .sandbox_policy - .set(SandboxPolicy::new_read_only_policy()) - .map_err(|err| format!("sandbox_policy is invalid: {err}"))?; - } - Ok(()) + #[tokio::test] + async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key() { + let (home, mut config) = test_config_with_cli_overrides(vec![( + "model".to_string(), + TomlValue::String("cli-model".to_string()), + )]) + .await; + let before_layers = session_flags_layer_count(&config); + let role_path = write_role_config(&home, "model-role.toml", "model = \"role-model\"").await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.model.as_deref(), Some("role-model")); + assert_eq!(session_flags_layer_count(&config), before_layers + 1); + } + + #[test] + fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() { + let user_defined_roles = BTreeMap::from([ + ( + "explorer".to_string(), + AgentRoleConfig { + description: Some("user override".to_string()), + config_file: None, + }, + ), + ("researcher".to_string(), AgentRoleConfig::default()), + ]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + + assert!(spec.contains("researcher: no description")); + assert!(spec.contains("explorer: {\nuser override\n}")); + assert!(spec.contains("default: {\nDefault agent.\n}")); + assert!(!spec.contains("Explorers are fast and authoritative.")); + } + + #[test] + fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() { + let user_defined_roles = BTreeMap::from([( + "aaa".to_string(), + AgentRoleConfig { + description: Some("first".to_string()), + config_file: None, + }, + )]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + let user_index = spec.find("aaa: {\nfirst\n}").expect("find user role"); + let built_in_index = spec + .find("default: {\nDefault agent.\n}") + .expect("find built-in role"); + + assert!(user_index < built_in_index); + } + + #[test] + fn built_in_config_file_contents_resolves_explorer_only() { + assert_eq!( + built_in::config_file_contents(Path::new("explorer.toml")), + Some(BUILT_IN_EXPLORER_CONFIG) + ); + assert_eq!( + built_in::config_file_contents(Path::new("missing.toml")), + None + ); } } diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index f450eb3a3a..7f706f737e 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -2,6 +2,6 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; pub(crate) fn render_apps_section() -> String { format!( - "## Apps\nApps are mentioned in the prompt in the format `[$app-name](apps://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either already provided in `{CODEX_APPS_MCP_SERVER_NAME}`, or do not exist because the user did not install it.\nDo not additionally call list_mcp_resources for apps that are already mentioned." + "## Apps\nApps are mentioned in the prompt in the format `[$app-name](app://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either already provided in `{CODEX_APPS_MCP_SERVER_NAME}`, or do not exist because the user did not install it.\nDo not additionally call list_mcp_resources for apps that are already mentioned." ) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a09238a3e7..afb9075c35 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -18,6 +18,7 @@ use crate::analytics_client::AppInvocation; use crate::analytics_client::build_track_events_context; use crate::app_tool_policy; use crate::apps::render_apps_section; +use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; @@ -32,7 +33,6 @@ use crate::models_manager::manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; use crate::rollout::session_index; -use crate::sandbox_tags::sandbox_tag; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; @@ -46,6 +46,7 @@ use async_channel::Sender; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; +use codex_hooks::HookResult; use codex_hooks::Hooks; use codex_hooks::HooksConfig; use codex_network_proxy::NetworkProxy; @@ -152,6 +153,7 @@ use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only; use crate::mcp_connection_manager::filter_mcp_tools_by_name; +use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; use crate::memories; use crate::mentions::build_connector_slug_counts; use crate::mentions::build_skill_name_counts; @@ -172,6 +174,9 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; use crate::protocol::McpServerRefreshConfig; +use crate::protocol::ModelRerouteEvent; +use crate::protocol::ModelRerouteReason; +use crate::protocol::NetworkApprovalContext; use crate::protocol::Op; use crate::protocol::PlanDeltaEvent; use crate::protocol::RateLimitSnapshot; @@ -224,7 +229,11 @@ use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; use crate::tools::js_repl::JsReplHandle; +use crate::tools::network_approval::NetworkApprovalService; +use crate::tools::network_approval::build_blocked_request_observer; +use crate::tools::network_approval::build_network_policy_decider; use crate::tools::parallel::ToolCallRuntime; use crate::tools::sandboxing::ApprovalStore; use crate::tools::spec::ToolsConfig; @@ -274,6 +283,8 @@ pub struct CodexSpawnOk { pub(crate) const INITIAL_SUBMIT_ID: &str = ""; pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 64; +const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber"; +const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety"; impl Codex { /// Spawn a new [`Codex`] and initialize the session. @@ -320,15 +331,11 @@ impl Codex { let config = Arc::new(config); let _ = models_manager - .list_models( - &config, - crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, - ) + .list_models(crate::models_manager::manager::RefreshStrategy::OnlineIfUncached) .await; let model = models_manager .get_default_model( &config.model, - &config, crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, ) .await; @@ -521,7 +528,6 @@ pub(crate) struct Session { js_repl: Arc, next_internal_sub_id: AtomicU64, } - /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -557,7 +563,7 @@ pub(crate) struct TurnContext { pub(crate) truncation_policy: TruncationPolicy, pub(crate) js_repl: Arc, pub(crate) dynamic_tools: Vec, - turn_metadata_state: Arc, + pub(crate) turn_metadata_state: Arc, } impl TurnContext { pub(crate) fn model_context_window(&self) -> Option { @@ -602,7 +608,8 @@ impl TurnContext { model_info: &model_info, features: &features, web_search_mode: self.tools_config.web_search_mode, - }); + }) + .with_agent_roles(config.agent_roles.clone()); Self { sub_id: self.sub_id.clone(), @@ -687,18 +694,6 @@ impl TurnContext { denied_domains: network.denied_domains.clone().unwrap_or_default(), }) } - - pub fn current_turn_metadata_header(&self) -> Option { - self.turn_metadata_state.current_header_value() - } - - pub fn spawn_turn_metadata_enrichment_task(self: &Arc) { - self.turn_metadata_state.spawn_git_enrichment_task(); - } - - pub fn cancel_turn_metadata_enrichment_task(&self) { - self.turn_metadata_state.cancel_git_enrichment_task(); - } } #[derive(Clone)] @@ -836,6 +831,33 @@ impl Session { } } + async fn start_managed_network_proxy( + spec: &crate::config::NetworkProxySpec, + sandbox_policy: &SandboxPolicy, + network_policy_decider: Option>, + blocked_request_observer: Option>, + managed_network_requirements_enabled: bool, + ) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> { + let network_proxy = spec + .start_proxy( + sandbox_policy, + network_policy_decider, + blocked_request_observer, + managed_network_requirements_enabled, + ) + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?; + let session_network_proxy = { + let proxy = network_proxy.proxy(); + SessionNetworkProxyRuntime { + http_addr: proxy.http_addr().to_string(), + socks_addr: proxy.socks_addr().to_string(), + admin_addr: proxy.admin_addr().to_string(), + } + }; + Ok((network_proxy, session_network_proxy)) + } + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config @@ -921,19 +943,18 @@ impl Session { model_info: &model_info, features: &per_turn_config.features, web_search_mode: Some(per_turn_config.web_search_mode.value()), - }); + }) + .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); let turn_metadata_state = Arc::new(TurnMetadataState::new( sub_id.clone(), cwd.clone(), - Some( - sandbox_tag( - session_configuration.sandbox_policy.get(), - session_configuration.windows_sandbox_level, - ) - .to_string(), - ), + session_configuration.sandbox_policy.get(), + session_configuration.windows_sandbox_level, + per_turn_config + .features + .enabled(Feature::UseLinuxSandboxBwrap), )); TurnContext { sub_id, @@ -1197,21 +1218,50 @@ impl Session { }; session_configuration.thread_name = thread_name.clone(); let mut state = SessionState::new(session_configuration.clone()); - let network_proxy = - match config.permissions.network.as_ref() { - Some(spec) => Some(spec.start_proxy().await.map_err(|err| { - anyhow::anyhow!("failed to start managed network proxy: {err}") - })?), - None => None, + let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + let network_approval = Arc::new(NetworkApprovalService::default()); + // The managed proxy can call back into core for allowlist-miss decisions. + let network_policy_decider_session = if managed_network_requirements_enabled { + config + .permissions + .network + .as_ref() + .map(|_| Arc::new(RwLock::new(std::sync::Weak::::new()))) + } else { + None + }; + let blocked_request_observer = if managed_network_requirements_enabled { + config + .permissions + .network + .as_ref() + .map(|_| build_blocked_request_observer(Arc::clone(&network_approval))) + } else { + None + }; + let network_policy_decider = + network_policy_decider_session + .as_ref() + .map(|network_policy_decider_session| { + build_network_policy_decider( + Arc::clone(&network_approval), + Arc::clone(network_policy_decider_session), + ) + }); + let (network_proxy, session_network_proxy) = + if let Some(spec) = config.permissions.network.as_ref() { + let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( + spec, + config.permissions.sandbox_policy.get(), + network_policy_decider.as_ref().map(Arc::clone), + blocked_request_observer.as_ref().map(Arc::clone), + managed_network_requirements_enabled, + ) + .await?; + (Some(network_proxy), Some(session_network_proxy)) + } else { + (None, None) }; - let session_network_proxy = network_proxy.as_ref().map(|started| { - let proxy = started.proxy(); - SessionNetworkProxyRuntime { - http_addr: proxy.http_addr().to_string(), - socks_addr: proxy.socks_addr().to_string(), - admin_addr: proxy.admin_addr().to_string(), - } - }); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -1237,6 +1287,7 @@ impl Session { file_watcher, agent_control, network_proxy, + network_approval: Arc::clone(&network_approval), state_db: state_db_ctx.clone(), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -1279,6 +1330,10 @@ impl Session { js_repl, next_internal_sub_id: AtomicU64::new(0), }); + if let Some(network_policy_decider_session) = network_policy_decider_session { + let mut guard = network_policy_decider_session.write().await; + *guard = Arc::downgrade(&sess); + } // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. @@ -1441,6 +1496,11 @@ impl Session { state.merge_mcp_tool_selection(tool_names) } + pub(crate) async fn set_mcp_tool_selection(&self, tool_names: Vec) { + let mut state = self.state.lock().await; + state.set_mcp_tool_selection(tool_names); + } + pub(crate) async fn get_mcp_tool_selection(&self) -> Option> { let state = self.state.lock().await; state.get_mcp_tool_selection() @@ -1474,6 +1534,7 @@ impl Session { async fn record_initial_history(&self, conversation_history: InitialHistory) { let turn_context = self.new_default_turn().await; + self.clear_mcp_tool_selection().await; match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) @@ -1489,6 +1550,8 @@ impl Session { } InitialHistory::Resumed(resumed_history) => { let rollout_items = resumed_history.history; + let restored_tool_selection = + Self::extract_mcp_tool_selection_from_rollout(&rollout_items); let previous_model = Self::last_rollout_model_name(&rollout_items) .map(std::string::ToString::to_string); { @@ -1530,12 +1593,17 @@ impl Session { let mut state = self.state.lock().await; state.set_token_info(Some(info)); } + if let Some(selected_tools) = restored_tool_selection { + self.set_mcp_tool_selection(selected_tools).await; + } // Defer seeding the session's initial context until the first turn starts so // turn/start overrides can be merged before we write to the rollout. self.flush_rollout().await; } InitialHistory::Forked(rollout_items) => { + let restored_tool_selection = + Self::extract_mcp_tool_selection_from_rollout(&rollout_items); let previous_model = Self::last_rollout_model_name(&rollout_items) .map(std::string::ToString::to_string); self.set_previous_model(previous_model).await; @@ -1555,6 +1623,9 @@ impl Session { let mut state = self.state.lock().await; state.set_token_info(Some(info)); } + if let Some(selected_tools) = restored_tool_selection { + self.set_mcp_tool_selection(selected_tools).await; + } // If persisting, persist all rollout items as-is (recorder filters) if !rollout_items.is_empty() { @@ -1596,6 +1667,54 @@ impl Session { }) } + fn extract_mcp_tool_selection_from_rollout( + rollout_items: &[RolloutItem], + ) -> Option> { + let mut search_call_ids = HashSet::new(); + let mut active_selected_tools: Option> = None; + + for item in rollout_items { + let RolloutItem::ResponseItem(response_item) = item else { + continue; + }; + match response_item { + ResponseItem::FunctionCall { name, call_id, .. } => { + if name == SEARCH_TOOL_BM25_TOOL_NAME { + search_call_ids.insert(call_id.clone()); + } + } + ResponseItem::FunctionCallOutput { call_id, output } => { + if !search_call_ids.contains(call_id) { + continue; + } + let Some(content) = output.body.to_text() else { + continue; + }; + let Ok(payload) = serde_json::from_str::(&content) else { + continue; + }; + let Some(selected_tools) = payload + .get("active_selected_tools") + .and_then(Value::as_array) + else { + continue; + }; + let Some(selected_tools) = selected_tools + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>() + else { + continue; + }; + active_selected_tools = Some(selected_tools); + } + _ => {} + } + } + + active_selected_tools + } + async fn previous_model(&self) -> Option { let state = self.state.lock().await; state.previous_model() @@ -1759,10 +1878,25 @@ impl Session { turn_context.final_output_json_schema = final_schema; } let turn_context = Arc::new(turn_context); - turn_context.spawn_turn_metadata_enrichment_task(); + turn_context.turn_metadata_state.spawn_git_enrichment_task(); turn_context } + pub(crate) async fn maybe_emit_unknown_model_warning_for_turn(&self, tc: &TurnContext) { + if tc.model_info.used_fallback_model_metadata { + self.send_event( + tc, + EventMsg::Warning(WarningEvent { + message: format!( + "Model metadata for `{}` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.", + tc.model_info.slug + ), + }), + ) + .await; + } + } + pub(crate) async fn new_default_turn(&self) -> Arc { self.new_default_turn_with_sub_id(self.next_internal_sub_id()) .await @@ -1837,155 +1971,38 @@ impl Session { state.session_configuration.collaboration_mode.clone() } - fn build_environment_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - let prev = previous?; - - let shell = self.user_shell(); - let prev_context = EnvironmentContext::from_turn_context(prev.as_ref(), shell.as_ref()); - let next_context = EnvironmentContext::from_turn_context(next, shell.as_ref()); - if prev_context.equals_except_shell(&next_context) { - return None; - } - Some(ResponseItem::from(EnvironmentContext::diff( - prev.as_ref(), - next, - shell.as_ref(), - ))) - } - - fn build_permissions_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - let prev = previous?; - if prev.sandbox_policy == next.sandbox_policy - && prev.approval_policy == next.approval_policy - { - return None; - } - - Some( - DeveloperInstructions::from_policy( - &next.sandbox_policy, - next.approval_policy, - self.services.exec_policy.current().as_ref(), - self.features.enabled(Feature::RequestRule), - &next.cwd, - ) - .into(), - ) - } - - fn build_personality_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - if !self.features.enabled(Feature::Personality) { - return None; - } - let previous = previous?; - if next.model_info.slug != previous.model_info.slug { - return None; - } - - // if a personality is specified and it's different from the previous one, build a personality update item - if let Some(personality) = next.personality - && next.personality != previous.personality - { - let model_info = &next.model_info; - let personality_message = Self::personality_message_for(model_info, personality); - personality_message.map(|personality_message| { - DeveloperInstructions::personality_spec_message(personality_message).into() + pub(crate) fn is_model_switch_developer_message(item: &ResponseItem) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + role == "developer" + && content.iter().any(|content_item| { + matches!( + content_item, + ContentItem::InputText { text } if text.starts_with("") + ) }) - } else { - None - } } - - fn personality_message_for(model_info: &ModelInfo, personality: Personality) -> Option { - model_info - .model_messages - .as_ref() - .and_then(|spec| spec.get_personality_message(Some(personality))) - .filter(|message| !message.is_empty()) - } - - fn build_collaboration_mode_update_item( - &self, - previous: Option<&Arc>, - next: &TurnContext, - ) -> Option { - let prev = previous?; - if prev.collaboration_mode != next.collaboration_mode { - // If the next mode has empty developer instructions, this returns None and we emit no - // update, so prior collaboration instructions remain in the prompt history. - Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into()) - } else { - None - } - } - - fn build_model_instructions_update_item( - &self, - previous: Option<&Arc>, - resumed_model: Option<&str>, - next: &TurnContext, - ) -> Option { - let previous_model = - resumed_model.or_else(|| previous.map(|prev| prev.model_info.slug.as_str()))?; - if previous_model == next.model_info.slug { - return None; - } - - let model_instructions = next.model_info.get_model_instructions(next.personality); - if model_instructions.is_empty() { - return None; - } - - Some(DeveloperInstructions::model_switch_message(model_instructions).into()) - } - fn build_settings_update_items( &self, previous_context: Option<&Arc>, resumed_model: Option<&str>, current_context: &TurnContext, ) -> Vec { - let mut update_items = Vec::new(); - if let Some(env_item) = - self.build_environment_update_item(previous_context, current_context) - { - update_items.push(env_item); - } - if let Some(permissions_item) = - self.build_permissions_update_item(previous_context, current_context) - { - update_items.push(permissions_item); - } - if let Some(collaboration_mode_item) = - self.build_collaboration_mode_update_item(previous_context, current_context) - { - update_items.push(collaboration_mode_item); - } - if let Some(model_instructions_item) = self.build_model_instructions_update_item( - previous_context, + // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem + // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible + // context (shell, exec policy, feature gates, resumed model bridge) should be persisted + // state or explicit non-state replay events. + let shell = self.user_shell(); + let exec_policy = self.services.exec_policy.current(); + crate::context_manager::updates::build_settings_update_items( + previous_context.map(Arc::as_ref), resumed_model, current_context, - ) { - update_items.push(model_instructions_item); - } - if let Some(personality_item) = - self.build_personality_update_item(previous_context, current_context) - { - update_items.push(personality_item); - } - update_items + shell.as_ref(), + exec_policy.as_ref(), + self.features.enabled(Feature::Personality), + ) } /// Persist the event to rollout and send it to clients. @@ -2088,7 +2105,7 @@ impl Session { Ok(()) } - async fn turn_context_for_sub_id(&self, sub_id: &str) -> Option> { + pub(crate) async fn turn_context_for_sub_id(&self, sub_id: &str) -> Option> { let active = self.active_turn.lock().await; active .as_ref() @@ -2150,6 +2167,7 @@ impl Session { command: Vec, cwd: PathBuf, reason: Option, + network_approval_context: Option, proposed_execpolicy_amendment: Option, ) -> ReviewDecision { // Add the tx_approve callback to the map before sending the request. @@ -2176,6 +2194,7 @@ impl Session { command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, parsed_cmd, }); @@ -2416,6 +2435,45 @@ impl Session { self.record_conversation_items(ctx, &[item]).await; } + async fn maybe_warn_on_server_model_mismatch( + self: &Arc, + turn_context: &Arc, + server_model: String, + ) -> bool { + let requested_model = turn_context.model_info.slug.clone(); + if server_model == requested_model { + info!("server reported model {server_model} (matches requested model)"); + return false; + } + + warn!("server reported model {server_model} while requested model was {requested_model}"); + + let warning_message = format!( + "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: {CYBER_VERIFY_URL} or learn more: {CYBER_SAFETY_URL}" + ); + + self.send_event( + turn_context, + EventMsg::ModelReroute(ModelRerouteEvent { + from_model: requested_model.clone(), + to_model: server_model.clone(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }), + ) + .await; + + self.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: warning_message.clone(), + }), + ) + .await; + self.record_model_warning(warning_message, turn_context) + .await; + true + } + pub(crate) async fn replace_history(&self, items: Vec) { let mut state = self.state.lock().await; state.replace_history(items); @@ -2479,7 +2537,6 @@ impl Session { &turn_context.sandbox_policy, turn_context.approval_policy, self.services.exec_policy.current().as_ref(), - self.features.enabled(Feature::RequestRule), &turn_context.cwd, ) .into(), @@ -2515,7 +2572,10 @@ impl Session { && base_instructions == model_info.get_model_instructions(Some(personality)); if !has_baked_personality && let Some(personality_message) = - Self::personality_message_for(&model_info, personality) + crate::context_manager::updates::personality_message_for( + &model_info, + personality, + ) { items.push( DeveloperInstructions::personality_spec_message(personality_message).into(), @@ -2525,6 +2585,13 @@ impl Session { if turn_context.features.enabled(Feature::Apps) { items.push(DeveloperInstructions::new(render_apps_section()).into()); } + if turn_context.features.enabled(Feature::CodexGitCommit) + && let Some(commit_message_instruction) = commit_message_trailer_instruction( + turn_context.config.commit_attribution.as_deref(), + ) + { + items.push(DeveloperInstructions::new(commit_message_instruction).into()); + } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push( UserInstructions { @@ -3112,22 +3179,24 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ListSkills { cwds, force_reload } => { handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; } - Op::ListRemoteSkills => { - handlers::list_remote_skills(&sess, &config, sub.id.clone()).await; - } - Op::DownloadRemoteSkill { - hazelnut_id, - is_preload, + Op::ListRemoteSkills { + hazelnut_scope, + product_surface, + enabled, } => { - handlers::download_remote_skill( + handlers::list_remote_skills( &sess, &config, sub.id.clone(), - hazelnut_id, - is_preload, + hazelnut_scope, + product_surface, + enabled, ) .await; } + Op::DownloadRemoteSkill { hazelnut_id } => { + handlers::export_remote_skill(&sess, &config, sub.id.clone(), hazelnut_id).await; + } Op::Undo => { handlers::undo(&sess, sub.id.clone()).await; } @@ -3207,6 +3276,8 @@ mod handlers { use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RemoteSkillDownloadedEvent; + use codex_protocol::protocol::RemoteSkillHazelnutScope; + use codex_protocol::protocol::RemoteSkillProductSurface; use codex_protocol::protocol::RemoteSkillSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; @@ -3316,6 +3387,8 @@ mod handlers { // new_turn_with_sub_id already emits the error event. return; }; + sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) + .await; current_context.otel_manager.user_prompt(&items); // Attempt to inject input into current task. @@ -3601,19 +3674,33 @@ mod handlers { sess.send_event_raw(event).await; } - pub async fn list_remote_skills(sess: &Session, config: &Arc, sub_id: String) { - let response = crate::skills::remote::list_remote_skills(config) - .await - .map(|skills| { - skills - .into_iter() - .map(|skill| RemoteSkillSummary { - id: skill.id, - name: skill.name, - description: skill.description, - }) - .collect::>() - }); + pub async fn list_remote_skills( + sess: &Session, + config: &Arc, + sub_id: String, + hazelnut_scope: RemoteSkillHazelnutScope, + product_surface: RemoteSkillProductSurface, + enabled: Option, + ) { + let auth = sess.services.auth_manager.auth().await; + let response = crate::skills::remote::list_remote_skills( + config, + auth.as_ref(), + hazelnut_scope, + product_surface, + enabled, + ) + .await + .map(|skills| { + skills + .into_iter() + .map(|skill| RemoteSkillSummary { + id: skill.id, + name: skill.name, + description: skill.description, + }) + .collect::>() + }); match response { Ok(skills) => { @@ -3638,22 +3725,27 @@ mod handlers { } } - pub async fn download_remote_skill( + pub async fn export_remote_skill( sess: &Session, config: &Arc, sub_id: String, hazelnut_id: String, - is_preload: bool, ) { - match crate::skills::remote::download_remote_skill(config, hazelnut_id.as_str(), is_preload) - .await + let auth = sess.services.auth_manager.auth().await; + match crate::skills::remote::export_remote_skill( + config, + auth.as_ref(), + hazelnut_id.as_str(), + ) + .await { Ok(result) => { + let id = result.id; let event = Event { id: sub_id, msg: EventMsg::RemoteSkillDownloaded(RemoteSkillDownloadedEvent { - id: result.id, - name: result.name, + id: id.clone(), + name: id, path: result.path, }), }; @@ -3663,7 +3755,7 @@ mod handlers { let event = Event { id: sub_id, msg: EventMsg::Error(ErrorEvent { - message: format!("failed to download remote skill {hazelnut_id}: {err}"), + message: format!("failed to export remote skill {hazelnut_id}: {err}"), codex_error_info: Some(CodexErrorInfo::Other), }), }; @@ -3920,6 +4012,8 @@ mod handlers { review_request: ReviewRequest, ) { let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; + sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; sess.refresh_mcp_servers_if_requested(&turn_context).await; match resolve_review_request(review_request, turn_context.cwd.as_path()) { Ok(resolved) => { @@ -3973,7 +4067,8 @@ async fn spawn_review_thread( model_info: &review_model_info, features: &review_features, web_search_mode: Some(review_web_search_mode), - }); + }) + .with_agent_roles(config.agent_roles.clone()); let review_prompt = resolved.prompt.clone(); let provider = parent_turn_context.provider.clone(); @@ -4010,13 +4105,11 @@ async fn spawn_review_thread( let turn_metadata_state = Arc::new(TurnMetadataState::new( review_turn_id.clone(), parent_turn_context.cwd.clone(), - Some( - sandbox_tag( - &parent_turn_context.sandbox_policy, - parent_turn_context.windows_sandbox_level, - ) - .to_string(), - ), + &parent_turn_context.sandbox_policy, + parent_turn_context.windows_sandbox_level, + parent_turn_context + .features + .enabled(Feature::UseLinuxSandboxBwrap), )); let review_turn_context = TurnContext { @@ -4059,7 +4152,7 @@ async fn spawn_review_thread( text_elements: Vec::new(), }]; let tc = Arc::new(review_turn_context); - tc.spawn_turn_metadata_enrichment_task(); + tc.turn_metadata_state.spawn_git_enrichment_task(); sess.spawn_task(tc.clone(), input, ReviewTask::new()).await; // Announce entering review mode so UIs can switch modes. @@ -4290,6 +4383,7 @@ pub(crate) async fn run_turn( // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let mut server_model_warning_emitted_for_turn = false; // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse // one instance across retries within this turn. @@ -4342,7 +4436,7 @@ pub(crate) async fn run_turn( }) .map(|user_message| user_message.message()) .collect::>(); - let turn_metadata_header = turn_context.current_turn_metadata_header(); + let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), @@ -4352,6 +4446,7 @@ pub(crate) async fn run_turn( sampling_request_input, &explicitly_enabled_connectors, skills_outcome.as_ref(), + &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), ) .await @@ -4387,7 +4482,8 @@ pub(crate) async fn run_turn( if !needs_follow_up { last_agent_message = sampling_request_last_agent_message; - sess.hooks() + let hook_outcomes = sess + .hooks() .dispatch(HookPayload { session_id: sess.conversation_id, cwd: turn_context.cwd.clone(), @@ -4402,6 +4498,47 @@ pub(crate) async fn run_turn( }, }) .await; + + let mut abort_message = None; + for hook_outcome in hook_outcomes { + let hook_name = hook_outcome.hook_name; + match hook_outcome.result { + HookResult::Success => {} + HookResult::FailedContinue(error) => { + warn!( + turn_id = %turn_context.sub_id, + hook_name = %hook_name, + error = %error, + "after_agent hook failed; continuing" + ); + } + HookResult::FailedAbort(error) => { + let message = format!( + "after_agent hook '{hook_name}' failed and aborted turn completion: {error}" + ); + warn!( + turn_id = %turn_context.sub_id, + hook_name = %hook_name, + error = %error, + "after_agent hook failed; aborting operation" + ); + if abort_message.is_none() { + abort_message = Some(message); + } + } + } + } + if let Some(message) = abort_message { + sess.send_event( + &turn_context, + EventMsg::Error(ErrorEvent { + message, + codex_error_info: None, + }), + ) + .await; + return None; + } break; } continue; @@ -4462,6 +4599,12 @@ async fn run_pre_sampling_compact( Ok(()) } +/// Runs pre-sampling compaction against the previous model when switching to a smaller +/// context-window model. +/// +/// Returns `Ok(())` when compaction either completed successfully or was skipped because the +/// model/context-window preconditions were not met. Returns `Err(_)` only when compaction was +/// attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, @@ -4682,6 +4825,7 @@ async fn run_sampling_request( input: Vec, explicitly_enabled_connectors: &HashSet, skills_outcome: Option<&SkillLoadOutcome>, + server_model_warning_emitted_for_turn: &mut bool, cancellation_token: CancellationToken, ) -> CodexResult { let router = built_tools( @@ -4718,6 +4862,7 @@ async fn run_sampling_request( client_session, turn_metadata_header, Arc::clone(&turn_diff_tracker), + server_model_warning_emitted_for_turn, &prompt, cancellation_token.child_token(), ) @@ -4807,14 +4952,13 @@ async fn built_tools( skills_outcome: Option<&SkillLoadOutcome>, cancellation_token: &CancellationToken, ) -> CodexResult> { - let mut mcp_tools = sess - .services - .mcp_connection_manager - .read() - .await + let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; + let has_mcp_servers = mcp_connection_manager.has_servers(); + let mut mcp_tools = mcp_connection_manager .list_all_tools() .or_cancel(cancellation_token) .await?; + drop(mcp_connection_manager); let apps_config = app_tool_policy::read_apps_config(&turn_context.config); let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone(); @@ -4829,6 +4973,10 @@ async fn built_tools( None }; + let app_tools = connectors.as_ref().map(|connectors| { + filter_codex_apps_mcp_tools(&mcp_tools, connectors, apps_config.as_ref()) + }); + if let Some(connectors) = connectors.as_ref() { let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1 @@ -4841,33 +4989,28 @@ async fn built_tools( &skill_name_counts_lower, ); - let mut selected_mcp_tools = - if let Some(selected_tools) = sess.get_mcp_tool_selection().await { - filter_mcp_tools_by_name(&mcp_tools, &selected_tools) - } else { - HashMap::new() - }; + let mut selected_mcp_tools = filter_non_codex_apps_mcp_tools_only(&mcp_tools); - let apps_mcp_tools = - filter_codex_apps_mcp_tools_only(&mcp_tools, explicitly_enabled.as_ref()); - selected_mcp_tools.extend(apps_mcp_tools); + if let Some(selected_tools) = sess.get_mcp_tool_selection().await { + selected_mcp_tools.extend(filter_mcp_tools_by_name(&mcp_tools, &selected_tools)); + } + + selected_mcp_tools.extend(filter_codex_apps_mcp_tools_only( + &mcp_tools, + explicitly_enabled.as_ref(), + )); mcp_tools = filter_codex_apps_mcp_tools(&selected_mcp_tools, connectors, apps_config.as_ref()); } - - let app_tools = connectors.as_ref().map(|connectors| { - filter_codex_apps_mcp_tools(&mcp_tools, connectors, apps_config.as_ref()) - }); - Ok(Arc::new(ToolRouter::from_config( &turn_context.tools_config, - Some( + has_mcp_servers.then(|| { mcp_tools .into_iter() .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), + .collect() + }), app_tools, turn_context.dynamic_tools.as_slice(), ))) @@ -5289,6 +5432,7 @@ async fn try_run_sampling_request( client_session: &mut ModelClientSession, turn_metadata_header: Option<&str>, turn_diff_tracker: SharedTurnDiffTracker, + server_model_warning_emitted_for_turn: &mut bool, prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { @@ -5431,6 +5575,15 @@ async fn try_run_sampling_request( active_item = Some(turn_item); } } + ResponseEvent::ServerModel(server_model) => { + if !*server_model_warning_emitted_for_turn + && sess + .maybe_warn_on_server_model_mismatch(&turn_context, server_model) + .await + { + *server_model_warning_emitted_for_turn = true; + } + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } @@ -5441,11 +5594,7 @@ async fn try_run_sampling_request( } ResponseEvent::ModelsEtag(etag) => { // Update internal state with latest models etag - let config = sess.get_config().await; - sess.services - .models_manager - .refresh_if_new_etag(etag, &config) - .await; + sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { response_id: _, @@ -5690,6 +5839,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: true, is_enabled: true, @@ -5751,6 +5903,22 @@ mod tests { } } + fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem { + RolloutItem::ResponseItem(ResponseItem::FunctionCall { + id: None, + name: name.to_string(), + arguments: "{}".to_string(), + call_id: call_id.to_string(), + }) + } + + fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem { + RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload::from_text(output.to_string()), + }) + } + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = @@ -5940,6 +6108,46 @@ mod tests { assert_eq!(connector_ids, HashSet::::new()); } + #[test] + fn non_app_mcp_tools_remain_visible_without_search_selection() { + let mcp_tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_create_event".to_string(), + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some("calendar"), + Some("Calendar"), + ), + ), + ( + "mcp__rmcp__echo".to_string(), + make_mcp_tool("rmcp", "echo", None, None), + ), + ]); + + let mut selected_mcp_tools = mcp_tools + .iter() + .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect::>(); + + let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let explicitly_enabled_connectors = HashSet::new(); + let connectors = filter_connectors_for_input( + &connectors, + &[user_message("run echo")], + &explicitly_enabled_connectors, + &HashMap::new(), + ); + let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); + selected_mcp_tools.extend(apps_mcp_tools); + + let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); + tool_names.sort(); + assert_eq!(tool_names, vec!["mcp__rmcp__echo".to_string()]); + } + #[test] fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { let selected_tool_names = vec![ @@ -6053,6 +6261,105 @@ mod tests { ); } + #[test] + fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() { + let rollout_items = vec![ + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": [ + "mcp__codex_apps__calendar_create_event", + "mcp__codex_apps__calendar_list_events", + ], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec![ + "mcp__codex_apps__calendar_create_event".to_string(), + "mcp__codex_apps__calendar_list_events".to_string(), + ]) + ); + } + + #[test] + fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() { + let rollout_items = vec![ + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_create_event"], + }) + .to_string(), + ), + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"), + function_call_output_rollout_item( + "search-2", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_delete_event"], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),]) + ); + } + + #[test] + fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() { + let rollout_items = vec![ + function_call_rollout_item("shell", "shell-1"), + function_call_output_rollout_item( + "shell-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__should_be_ignored"], + }) + .to_string(), + ), + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item("search-1", "{not-json"), + function_call_output_rollout_item( + "unknown-search-call", + &json!({ + "active_selected_tools": ["mcp__codex_apps__also_ignored"], + }) + .to_string(), + ), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_list_events"], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),]) + ); + } + + #[test] + fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() { + let rollout_items = vec![function_call_rollout_item( + SEARCH_TOOL_BM25_TOOL_NAME, + "search-1", + )]; + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!(selected, None); + } #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; @@ -7017,6 +7324,7 @@ mod tests { let mut state = SessionState::new(session_configuration.clone()); mark_state_initial_context_seeded(&mut state); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); + let network_approval = Arc::new(NetworkApprovalService::default()); let file_watcher = Arc::new(FileWatcher::noop()); let services = SessionServices { @@ -7043,6 +7351,7 @@ mod tests { file_watcher, agent_control, network_proxy: None, + network_approval: Arc::clone(&network_approval), state_db: None, model_client: ModelClient::new( Some(auth_manager.clone()), @@ -7163,6 +7472,7 @@ mod tests { let mut state = SessionState::new(session_configuration.clone()); mark_state_initial_context_seeded(&mut state); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); + let network_approval = Arc::new(NetworkApprovalService::default()); let file_watcher = Arc::new(FileWatcher::noop()); let services = SessionServices { @@ -7189,6 +7499,7 @@ mod tests { file_watcher, agent_control, network_proxy: None, + network_approval: Arc::clone(&network_approval), state_db: None, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -7951,6 +8262,7 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, + network_attempt_id: None, sandbox_permissions, windows_sandbox_level: turn_context.windows_sandbox_level, justification: Some("test".to_string()), @@ -7964,6 +8276,7 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, + network_attempt_id: None, windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1f4240f683..69efeefeca 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -316,6 +316,7 @@ async fn handle_exec_approval( command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, .. } = event; @@ -327,6 +328,7 @@ async fn handle_exec_approval( command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, ); let decision = diff --git a/codex-rs/core/src/commit_attribution.rs b/codex-rs/core/src/commit_attribution.rs new file mode 100644 index 0000000000..a2c9104b25 --- /dev/null +++ b/codex-rs/core/src/commit_attribution.rs @@ -0,0 +1,76 @@ +const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; + +fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option { + let value = resolve_attribution_value(config_attribution)?; + Some(format!("Co-authored-by: {value}")) +} + +pub(crate) fn commit_message_trailer_instruction( + config_attribution: Option<&str>, +) -> Option { + let trailer = build_commit_message_trailer(config_attribution)?; + Some(format!( + "When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block." + )) +} + +fn resolve_attribution_value(config_attribution: Option<&str>) -> Option { + match config_attribution { + Some(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::build_commit_message_trailer; + use super::commit_message_trailer_instruction; + use super::resolve_attribution_value; + + #[test] + fn blank_attribution_disables_trailer_prompt() { + assert_eq!(build_commit_message_trailer(Some("")), None); + assert_eq!(commit_message_trailer_instruction(Some(" ")), None); + } + + #[test] + fn default_attribution_uses_codex_trailer() { + assert_eq!( + build_commit_message_trailer(None).as_deref(), + Some("Co-authored-by: Codex ") + ); + } + + #[test] + fn resolve_value_handles_default_custom_and_blank() { + assert_eq!( + resolve_attribution_value(None), + Some("Codex ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent ")), + Some("MyAgent ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent")), + Some("MyAgent".to_string()) + ); + assert_eq!(resolve_attribution_value(Some(" ")), None); + } + + #[test] + fn instruction_mentions_trailer_and_omits_generated_with() { + let instruction = commit_message_trailer_instruction(Some("AgentX ")) + .expect("instruction expected"); + assert!(instruction.contains("Co-authored-by: AgentX ")); + assert!(instruction.contains("exactly once")); + assert!(!instruction.contains("Generated-with")); + } +} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 08932bf7a3..a40f1a0b8e 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -7,6 +7,7 @@ use crate::client_common::ResponseEvent; use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::get_last_assistant_message_from_turn; +use crate::context_manager::ContextManager; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::CompactedItem; @@ -35,6 +36,31 @@ pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bo provider.is_openai() } +pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( + history: &mut ContextManager, +) -> Option { + let history_items = history.raw_items(); + let last_user_turn_boundary_index = history_items + .iter() + .rposition(crate::context_manager::is_user_turn_boundary); + let model_switch_index = history_items + .iter() + .enumerate() + .rev() + .find_map(|(i, item)| { + let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary); + if is_trailing && Session::is_model_switch_developer_message(item) { + Some(i) + } else { + None + } + })?; + let mut replacement = history_items.to_vec(); + let model_switch_item = replacement.remove(model_switch_index); + history.replace(replacement); + Some(model_switch_item) +} + pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, @@ -75,6 +101,10 @@ async fn run_compact_task_inner( let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let mut history = sess.clone_history().await; + // Keep compaction prompts in-distribution: if a model-switch update was injected at the + // tail of history (between turns), exclude it from the compaction request payload. + let stripped_model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); history.record_items( &[initial_input_for_turn.into()], turn_context.truncation_policy, @@ -109,7 +139,7 @@ async fn run_compact_task_inner( personality: turn_context.personality, ..Default::default() }; - let turn_metadata_header = turn_context.current_turn_metadata_header(); + let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); let attempt_result = drain_to_completed( &sess, turn_context.as_ref(), @@ -180,6 +210,11 @@ async fn run_compact_task_inner( let initial_context = sess.build_initial_context(turn_context.as_ref()).await; let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + // Reattach the stripped model-switch update only after successful compaction so the model + // still sees the switch instructions on the next real sampling request. + if let Some(model_switch_item) = stripped_model_switch_item { + new_history.push(model_switch_item); + } let ghost_snapshots: Vec = history_items .iter() .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) @@ -443,6 +478,107 @@ mod tests { assert_eq!(None, joined); } + #[test] + fn extract_trailing_model_switch_update_for_compaction_request_removes_trailing_item() { + let mut history = ContextManager::new(); + history.replace(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "ASSISTANT_REPLY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nNEW_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ]); + + let model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); + + assert_eq!(history.raw_items().len(), 2); + assert!(model_switch_item.is_some()); + assert!( + history + .raw_items() + .iter() + .all(|item| !Session::is_model_switch_developer_message(item)) + ); + } + + #[test] + fn extract_trailing_model_switch_update_for_compaction_request_keeps_historical_item() { + let mut history = ContextManager::new(); + history.replace(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "FIRST_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nOLDER_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "ASSISTANT_REPLY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "SECOND_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ]); + + let model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); + + assert_eq!(history.raw_items().len(), 4); + assert!(model_switch_item.is_none()); + assert!( + history + .raw_items() + .iter() + .any(Session::is_model_switch_developer_message) + ); + } + #[test] fn collect_user_messages_extracts_user_text_only() { let items = vec![ diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 4fe07560b9..3d181f8855 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; +use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; @@ -65,6 +66,10 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_started(turn_context, &compaction_item) .await; let mut history = sess.clone_history().await; + // Keep compaction prompts in-distribution: if a model-switch update was injected at the + // tail of history (between turns), exclude it from the compaction request payload. + let stripped_model_switch_item = + extract_trailing_model_switch_update_for_compaction_request(&mut history); let base_instructions = sess.get_base_instructions().await; let deleted_items = trim_function_call_history_to_fit_context_window( &mut history, @@ -120,6 +125,11 @@ async fn run_remote_compact_task_inner_impl( new_history = sess .process_compacted_history(turn_context, new_history) .await; + // Reattach the stripped model-switch update only after successful compaction so the model + // still sees the switch instructions on the next real sampling request. + if let Some(model_switch_item) = stripped_model_switch_item { + new_history.push(model_switch_item); + } if !ghost_snapshots.is_empty() { new_history.extend(ghost_snapshots); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5cfdcf0441..9874cc2774 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,6 +49,8 @@ use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; +#[cfg(target_os = "macos")] +use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use codex_app_server_protocol::Tools; @@ -78,6 +80,8 @@ use std::path::Path; use std::path::PathBuf; #[cfg(test)] use tempfile::tempdir; +#[cfg(not(target_os = "macos"))] +type MacOsSeatbeltProfileExtensions = (); use crate::config::profile::ConfigProfile; use toml::Value as TomlValue; @@ -133,6 +137,9 @@ pub struct Permissions { /// Effective Windows sandbox mode derived from `[windows].sandbox` or /// legacy feature keys. pub windows_sandbox_mode: Option, + /// Optional macOS seatbelt extension profile used to extend default + /// seatbelt permissions when running under seatbelt. + pub macos_seatbelt_profile_extensions: Option, } /// Application configuration loaded from disk and merged with overrides. @@ -199,6 +206,13 @@ pub struct Config { /// Compact prompt override. pub compact_prompt: Option, + /// Optional commit attribution text for commit message co-author trailers. + /// + /// - `None`: use default attribution (`Codex `) + /// - `Some("")` or whitespace-only: disable commit attribution + /// - `Some("...")`: use the provided attribution text verbatim + pub commit_attribution: Option, + /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command @@ -291,6 +305,9 @@ pub struct Config { /// Maximum number of agent threads that can be open concurrently. pub agent_max_threads: Option, + /// User-defined role declarations keyed by role name. + pub agent_roles: BTreeMap, + /// Memories subsystem settings. pub memories: MemoriesConfig, @@ -907,6 +924,11 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, + /// Optional commit attribution text for commit message co-author trailers. + /// + /// Set to an empty string to disable automatic commit attribution. + pub commit_attribution: Option, + /// When set, restricts ChatGPT login to a specific workspace identifier. #[serde(default)] pub forced_chatgpt_workspace_id: Option, @@ -1141,6 +1163,35 @@ pub struct AgentsToml { /// When unset, no limit is enforced. #[schemars(range(min = 1))] pub max_threads: Option, + + /// User-defined role declarations keyed by role name. + /// + /// Example: + /// ```toml + /// [agents.researcher] + /// description = "Research-focused role." + /// config_file = "./agents/researcher.toml" + /// ``` + #[serde(default, flatten)] + pub roles: BTreeMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AgentRoleConfig { + /// Human-facing role documentation used in spawn tool guidance. + pub description: Option, + /// Path to a role-specific config layer. + pub config_file: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentRoleToml { + /// Human-facing role documentation used in spawn tool guidance. + pub description: Option, + + /// Path to a role-specific config layer. + pub config_file: Option, } impl From for Tools { @@ -1410,7 +1461,7 @@ impl Config { Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack) } - fn load_config_with_layer_stack( + pub(crate) fn load_config_with_layer_stack( cfg: ConfigToml, overrides: ConfigOverrides, codex_home: PathBuf, @@ -1583,6 +1634,28 @@ impl Config { "agents.max_threads must be at least 1", )); } + let agent_roles = cfg + .agents + .as_ref() + .map(|agents| { + agents + .roles + .iter() + .map(|(name, role)| { + ( + name.clone(), + AgentRoleConfig { + description: role.description.clone(), + config_file: role + .config_file + .as_ref() + .map(AbsolutePathBuf::to_path_buf), + }, + ) + }) + .collect() + }) + .unwrap_or_default(); let ghost_snapshot = { let mut config = GhostSnapshotConfig::default(); @@ -1635,6 +1708,8 @@ impl Config { } }); + let commit_attribution = cfg.commit_attribution; + // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -1745,6 +1820,7 @@ impl Config { network, shell_environment_policy, windows_sandbox_mode, + macos_seatbelt_profile_extensions: None, }, enforce_residency: enforce_residency.value, did_user_set_custom_approval_policy_or_sandbox_mode, @@ -1754,6 +1830,7 @@ impl Config { personality, developer_instructions, compact_prompt, + commit_attribution, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), @@ -1779,6 +1856,7 @@ impl Config { .collect(), tool_output_token_limit: cfg.tool_output_token_limit, agent_max_threads, + agent_roles, memories: cfg.memories.unwrap_or_default().into(), codex_home, log_dir, @@ -1942,6 +2020,13 @@ impl Config { self.permissions.windows_sandbox_mode }; } + + pub fn managed_network_requirements_enabled(&self) -> bool { + self.config_layer_stack + .requirements_toml() + .network + .is_some() + } } pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { @@ -4084,6 +4169,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, @@ -4099,6 +4185,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), @@ -4120,6 +4207,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4194,6 +4282,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, @@ -4209,6 +4298,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), @@ -4230,6 +4320,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4302,6 +4393,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, @@ -4317,6 +4409,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), @@ -4338,6 +4431,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -4396,6 +4490,7 @@ model_verbosity = "high" network: None, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, }, enforce_residency: Constrained::allow_any(None), did_user_set_custom_approval_policy_or_sandbox_mode: true, @@ -4411,6 +4506,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), log_dir: fixture.codex_home().join("log"), @@ -4432,6 +4528,7 @@ model_verbosity = "high" base_instructions: None, developer_instructions: None, compact_prompt: None, + commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index ee3e8ebed1..183eafcb17 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -1,8 +1,11 @@ use crate::config; use crate::config_loader::NetworkConstraints; use async_trait::async_trait; +use codex_network_proxy::BlockedRequestObserver; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkDecision; +use codex_network_proxy::NetworkPolicyDecider; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkProxyConstraints; @@ -11,6 +14,7 @@ use codex_network_proxy::NetworkProxyState; use codex_network_proxy::build_config_state; use codex_network_proxy::host_and_port_from_network_addr; use codex_network_proxy::validate_policy_against_constraints; +use codex_protocol::protocol::SandboxPolicy; use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq)] @@ -92,20 +96,41 @@ impl NetworkProxySpec { }) } - pub async fn start_proxy(&self) -> std::io::Result { + pub async fn start_proxy( + &self, + sandbox_policy: &SandboxPolicy, + policy_decider: Option>, + blocked_request_observer: Option>, + enable_network_approval_flow: bool, + ) -> std::io::Result { let state = build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| { std::io::Error::other(format!("failed to build network proxy state: {err}")) })?; let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone())); let state = NetworkProxyState::with_reloader(state, reloader); - let proxy = NetworkProxy::builder() - .state(Arc::new(state)) - .build() - .await - .map_err(|err| { - std::io::Error::other(format!("failed to build network proxy: {err}")) - })?; + let mut builder = NetworkProxy::builder().state(Arc::new(state)); + if enable_network_approval_flow + && matches!( + sandbox_policy, + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } + ) + { + builder = match policy_decider { + Some(policy_decider) => builder.policy_decider_arc(policy_decider), + None => builder.policy_decider(|_request| async { + // In restricted sandbox modes, allowlist misses should ask for + // explicit network approval instead of hard-denying. + NetworkDecision::ask("not_allowed") + }), + }; + } + if let Some(blocked_request_observer) = blocked_request_observer { + builder = builder.blocked_request_observer_arc(blocked_request_observer); + } + let proxy = builder.build().await.map_err(|err| { + std::io::Error::other(format!("failed to build network proxy: {err}")) + })?; let handle = proxy .run() .await diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index c7cef49d57..5e1a69282a 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -777,7 +777,7 @@ unified_exec = true service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "features.remote_models".to_string(), + key_path: "features.personality".to_string(), value: serde_json::json!(true), merge_strategy: MergeStrategy::Replace, expected_version: None, @@ -797,7 +797,7 @@ hide_full_access_warning = true [features] unified_exec = true -remote_models = true +personality = true "#; assert_eq!(updated, expected); Ok(()) diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 841ef7004d..f30a627c40 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -688,7 +688,7 @@ async fn project_trust_context( /// /// This ensures that multiple config layers can be merged together correctly /// even if they were loaded from different directories. -fn resolve_relative_paths_in_config_toml( +pub(crate) fn resolve_relative_paths_in_config_toml( value_from_config_toml: TomlValue, base_dir: &Path, ) -> io::Result { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index a2b1352092..b4ed0e5c1c 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -7,7 +7,9 @@ use std::time::Duration; use std::time::Instant; use async_channel::unbounded; +pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; +pub use codex_app_server_protocol::AppMetadata; use codex_protocol::protocol::SandboxPolicy; use tokio_util::sync::CancellationToken; use tracing::warn; @@ -312,6 +314,9 @@ where logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some(connector_install_url(&connector_name, &connector_id)), is_accessible: true, is_enabled: true, diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index dcaf794c49..853f8af5ac 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -1,5 +1,6 @@ mod history; mod normalize; +pub(crate) mod updates; pub(crate) use history::ContextManager; pub(crate) use history::TotalTokenUsageBreakdown; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs new file mode 100644 index 0000000000..4a3f0c15e5 --- /dev/null +++ b/codex-rs/core/src/context_manager/updates.rs @@ -0,0 +1,148 @@ +use crate::codex::TurnContext; +use crate::environment_context::EnvironmentContext; +use crate::shell::Shell; +use codex_execpolicy::Policy; +use codex_protocol::config_types::Personality; +use codex_protocol::models::DeveloperInstructions; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelInfo; + +fn build_environment_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, + shell: &Shell, +) -> Option { + let prev = previous?; + let prev_context = EnvironmentContext::from_turn_context(prev, shell); + let next_context = EnvironmentContext::from_turn_context(next, shell); + if prev_context.equals_except_shell(&next_context) { + return None; + } + + Some(ResponseItem::from(EnvironmentContext::diff( + prev, next, shell, + ))) +} + +fn build_permissions_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, + exec_policy: &Policy, +) -> Option { + let prev = previous?; + if prev.sandbox_policy == next.sandbox_policy && prev.approval_policy == next.approval_policy { + return None; + } + + Some( + DeveloperInstructions::from_policy( + &next.sandbox_policy, + next.approval_policy, + exec_policy, + &next.cwd, + ) + .into(), + ) +} + +fn build_collaboration_mode_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, +) -> Option { + let prev = previous?; + if prev.collaboration_mode != next.collaboration_mode { + // If the next mode has empty developer instructions, this returns None and we emit no + // update, so prior collaboration instructions remain in the prompt history. + Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into()) + } else { + None + } +} + +fn build_personality_update_item( + previous: Option<&TurnContext>, + next: &TurnContext, + personality_feature_enabled: bool, +) -> Option { + if !personality_feature_enabled { + return None; + } + let previous = previous?; + if next.model_info.slug != previous.model_info.slug { + return None; + } + + if let Some(personality) = next.personality + && next.personality != previous.personality + { + let model_info = &next.model_info; + let personality_message = personality_message_for(model_info, personality); + personality_message + .map(|message| DeveloperInstructions::personality_spec_message(message).into()) + } else { + None + } +} + +pub(crate) fn personality_message_for( + model_info: &ModelInfo, + personality: Personality, +) -> Option { + model_info + .model_messages + .as_ref() + .and_then(|spec| spec.get_personality_message(Some(personality))) + .filter(|message| !message.is_empty()) +} + +pub(crate) fn build_model_instructions_update_item( + previous: Option<&TurnContext>, + resumed_model: Option<&str>, + next: &TurnContext, +) -> Option { + let previous_model = + resumed_model.or_else(|| previous.map(|prev| prev.model_info.slug.as_str()))?; + if previous_model == next.model_info.slug { + return None; + } + + let model_instructions = next.model_info.get_model_instructions(next.personality); + if model_instructions.is_empty() { + return None; + } + + Some(DeveloperInstructions::model_switch_message(model_instructions).into()) +} + +pub(crate) fn build_settings_update_items( + previous: Option<&TurnContext>, + resumed_model: Option<&str>, + next: &TurnContext, + shell: &Shell, + exec_policy: &Policy, + personality_feature_enabled: bool, +) -> Vec { + let mut update_items = Vec::new(); + + if let Some(env_item) = build_environment_update_item(previous, next, shell) { + update_items.push(env_item); + } + if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) { + update_items.push(permissions_item); + } + if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) { + update_items.push(collaboration_mode_item); + } + if let Some(model_instructions_item) = + build_model_instructions_update_item(previous, resumed_model, next) + { + update_items.push(model_instructions_item); + } + if let Some(personality_item) = + build_personality_update_item(previous, next, personality_feature_enabled) + { + update_items.push(personality_item); + } + + update_items +} diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 404fbc5a7a..ad49c611fc 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,4 +1,5 @@ use crate::exec::ExecToolCallOutput; +use crate::network_policy_decision::NetworkPolicyDecisionPayload; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use crate::truncate::TruncationPolicy; @@ -31,7 +32,10 @@ pub enum SandboxErr { "sandbox denied exec error, exit code: {}, stdout: {}, stderr: {}", .output.exit_code, .output.stdout.text, .output.stderr.text )] - Denied { output: Box }, + Denied { + output: Box, + network_policy_decision: Option, + }, /// Error from linux seccomp filter setup #[cfg(target_os = "linux")] @@ -616,7 +620,7 @@ impl CodexErr { pub fn get_error_message_ui(e: &CodexErr) -> String { let message = match e { - CodexErr::Sandbox(SandboxErr::Denied { output }) => { + CodexErr::Sandbox(SandboxErr::Denied { output, .. }) => { let aggregated = output.aggregated_output.text.trim(); if !aggregated.is_empty() { output.aggregated_output.text.clone() @@ -736,6 +740,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!(get_error_message_ui(&err), "aggregate detail"); } @@ -752,6 +757,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail"); } @@ -768,6 +774,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!(get_error_message_ui(&err), "stdout only"); } @@ -811,6 +818,7 @@ mod tests { }; let err = CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, }); assert_eq!( get_error_message_ui(&err), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 398a767ce0..0f0f0609a2 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -15,6 +15,7 @@ use tokio::io::AsyncReadExt; use tokio::io::BufReader; use tokio::process::Child; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::error::CodexErr; use crate::error::Result; @@ -66,6 +67,7 @@ pub struct ExecParams { pub expiration: ExecExpiration, pub env: HashMap, pub network: Option, + pub network_attempt_id: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub justification: Option, @@ -184,13 +186,15 @@ pub async fn process_exec_tool_call( mut env, expiration, network, + network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, arg0: _, } = params; + let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -238,6 +242,7 @@ pub(crate) async fn execute_exec_env( cwd, env, network, + network_attempt_id, expiration, sandbox, windows_sandbox_level, @@ -246,12 +251,18 @@ pub(crate) async fn execute_exec_env( arg0, } = env; + let network_attempt_id = match network_attempt_id.as_deref() { + Some(attempt_id) => Uuid::parse_str(attempt_id).ok(), + None => network.as_ref().map(|_| Uuid::new_v4()), + }; + let params = ExecParams { command, cwd, expiration, env, - network, + network: network.clone(), + network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, @@ -345,12 +356,14 @@ async fn exec_windows_sandbox( cwd, mut env, network, + network_attempt_id, expiration, windows_sandbox_level, .. } = params; + let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); } // TODO(iceweasel-oai): run_windows_sandbox_capture should support all @@ -490,6 +503,7 @@ fn finalize_exec_result( if is_likely_sandbox_denied(sandbox_type, &exec_output) { return Err(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(exec_output), + network_policy_decision: None, })); } @@ -701,13 +715,19 @@ async fn exec( let ExecParams { command, cwd, - env, + mut env, network, + network_attempt_id, arg0, expiration, windows_sandbox_level: _, .. } = params; + let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); + + if let Some(network) = network.as_ref() { + network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -722,7 +742,10 @@ async fn exec( arg0: arg0_ref, cwd, sandbox_policy, - network: network.as_ref(), + // The environment already has attempt-scoped proxy settings from + // apply_to_env_for_attempt above. Passing network here would reapply + // non-attempt proxy vars and drop attempt correlation metadata. + network: None, stdio_policy: StdioPolicy::RedirectForShellTool, env, }) @@ -951,6 +974,17 @@ mod tests { assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); } + #[test] + fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() { + let output = make_exec_output( + 0, + "", + "", + r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#, + ); + assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); + } + #[test] fn sandbox_detection_uses_aggregated_output() { let output = make_exec_output( @@ -965,6 +999,21 @@ mod tests { )); } + #[test] + fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() { + let output = make_exec_output( + 0, + "", + "", + r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#, + ); + + assert!(!is_likely_sandbox_denied( + SandboxType::LinuxSeccomp, + &output + )); + } + #[tokio::test] async fn read_capped_limits_retained_bytes() { let (mut writer, reader) = tokio::io::duplex(1024); @@ -1088,6 +1137,7 @@ mod tests { expiration: 500.into(), env, network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, @@ -1141,6 +1191,7 @@ mod tests { expiration: ExecExpiration::Cancellation(cancel_token), env, network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 298bc2679e..62ebdde43d 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -170,23 +170,26 @@ impl ExecPolicyManager { prefix_rule, } = req; let exec_policy = self.current(); - let (commands, used_heredoc_fallback) = commands_for_exec_policy(command); + let (commands, used_complex_parsing) = commands_for_exec_policy(command); // Keep heredoc prefix parsing for rule evaluation so existing // allow/prompt/forbidden rules still apply, but avoid auto-derived // amendments when only the heredoc fallback parser matched. - let auto_amendment_allowed = !used_heredoc_fallback; + let auto_amendment_allowed = !used_complex_parsing; let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( approval_policy, sandbox_policy, cmd, sandbox_permissions, + used_complex_parsing, ) }; let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback); - let requested_amendment = - derive_requested_execpolicy_amendment(prefix_rule.as_ref(), &evaluation.matched_rules); + let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule( + prefix_rule.as_ref(), + &evaluation.matched_rules, + ); match evaluation.decision { Decision::Forbidden => ExecApprovalRequirement::Forbidden { @@ -406,8 +409,9 @@ pub fn render_decision_for_unmatched_command( sandbox_policy: &SandboxPolicy, command: &[String], sandbox_permissions: SandboxPermissions, + used_complex_parsing: bool, ) -> Decision { - if is_known_safe_command(command) { + if is_known_safe_command(command) && !used_complex_parsing { return Decision::Allow; } @@ -534,7 +538,7 @@ fn try_derive_execpolicy_amendment_for_allow_rules( }) } -fn derive_requested_execpolicy_amendment( +fn derive_requested_execpolicy_amendment_from_prefix_rule( prefix_rule: Option<&Vec>, matched_rules: &[RuleMatch], ) -> Option { @@ -552,10 +556,8 @@ fn derive_requested_execpolicy_amendment( return None; } - if matched_rules - .iter() - .any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt) - { + // if any policy rule already matches, don't suggest an additional rule that might conflict or not apply + if matched_rules.iter().any(is_policy_match) { return None; } @@ -686,8 +688,6 @@ mod tests { use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; - use crate::features::Feature; - use crate::features::Features; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -1281,8 +1281,6 @@ prefix_rule( "cargo-insta".to_string(), ]; let manager = ExecPolicyManager::default(); - let mut features = Features::with_defaults(); - features.enable(Feature::RequestRule); let requirement = manager .create_exec_approval_requirement_for_command(ExecApprovalRequest { @@ -1564,14 +1562,17 @@ prefix_rule( #[test] fn derive_requested_execpolicy_amendment_returns_none_for_missing_prefix_rule() { - assert_eq!(None, derive_requested_execpolicy_amendment(None, &[])); + assert_eq!( + None, + derive_requested_execpolicy_amendment_from_prefix_rule(None, &[]) + ); } #[test] fn derive_requested_execpolicy_amendment_returns_none_for_empty_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&Vec::new()), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&Vec::new()), &[]) ); } @@ -1579,7 +1580,7 @@ prefix_rule( fn derive_requested_execpolicy_amendment_returns_none_for_exact_banned_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment( + derive_requested_execpolicy_amendment_from_prefix_rule( Some(&vec!["python".to_string(), "-c".to_string()]), &[], ) @@ -1598,7 +1599,7 @@ prefix_rule( ] { assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) ); } } @@ -1624,7 +1625,7 @@ prefix_rule( ] { assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) ); } } @@ -1639,22 +1640,52 @@ prefix_rule( assert_eq!( Some(ExecPolicyAmendment::new(prefix_rule.clone())), - derive_requested_execpolicy_amendment(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) ); } #[test] - fn derive_requested_execpolicy_amendment_returns_none_when_policy_prompt_matches() { + fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() { let prefix_rule = vec!["cargo".to_string(), "build".to_string()]; - let matched_rules = vec![RuleMatch::PrefixRuleMatch { + + let matched_rules_prompt = vec![RuleMatch::PrefixRuleMatch { matched_prefix: vec!["cargo".to_string()], decision: Decision::Prompt, justification: None, }]; - assert_eq!( None, - derive_requested_execpolicy_amendment(Some(&prefix_rule), &matched_rules) + derive_requested_execpolicy_amendment_from_prefix_rule( + Some(&prefix_rule), + &matched_rules_prompt + ), + "should return none when prompt policy matches" + ); + let matched_rules_allow = vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["cargo".to_string()], + decision: Decision::Allow, + justification: None, + }]; + assert_eq!( + None, + derive_requested_execpolicy_amendment_from_prefix_rule( + Some(&prefix_rule), + &matched_rules_allow + ), + "should return none when prompt policy matches" + ); + let matched_rules_forbidden = vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["cargo".to_string()], + decision: Decision::Forbidden, + justification: None, + }]; + assert_eq!( + None, + derive_requested_execpolicy_amendment_from_prefix_rule( + Some(&prefix_rule), + &matched_rules_forbidden + ), + "should return none when prompt policy matches" ); } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 33801c47ce..49363641bb 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -101,10 +101,12 @@ pub enum Feature { WindowsSandbox, /// Use the elevated Windows sandbox pipeline (setup + runner). WindowsSandboxElevated, - /// Refresh remote models and emit AppReady once the list is available. + /// Legacy remote models flag kept for backward compatibility. RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, + /// Enable git commit attribution guidance via model instructions. + CodexGitCommit, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, /// Persist rollout metadata to a local SQLite database. @@ -356,7 +358,8 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option } _ => alias, }; - let summary = format!("`{label}` is deprecated. Use `web_search` instead."); + let summary = + format!("`{label}` is deprecated because web search is enabled by default."); (summary, Some(web_search_details().to_string())) } _ => { @@ -374,7 +377,7 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option } fn web_search_details() -> &'static str { - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." + "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." } /// Keys accepted in `[features]` tables. @@ -465,6 +468,12 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: false, }, // Experimental program. Rendered in the `/experimental` menu for users. + FeatureSpec { + id: Feature::CodexGitCommit, + key: "codex_git_commit", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::RuntimeMetrics, key: "runtime_metrics", @@ -511,8 +520,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RequestRule, key: "request_rule", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::WindowsSandbox, @@ -529,8 +538,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RemoteModels, key: "remote_models", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::PowershellUtf8, @@ -552,11 +561,11 @@ pub const FEATURES: &[FeatureSpec] = &[ }, FeatureSpec { id: Feature::Collab, - key: "collab", + key: "multi_agent", stage: Stage::Experimental { - name: "Sub-agents", + name: "Multi-agents", menu_description: "Ask Codex to spawn multiple agents to parallelize the work and win in efficiency.", - announcement: "NEW: Sub-agents can now be spawned by Codex. Enable in /experimental and restart Codex!", + announcement: "NEW: Multi-agents can now be spawned by Codex. Enable in /experimental and restart Codex!", }, default_enabled: false, }, @@ -738,4 +747,10 @@ mod tests { ); assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false); } + + #[test] + fn collab_is_legacy_alias_for_multi_agent() { + assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); + assert_eq!(feature_for_key("collab"), Some(Feature::Collab)); + } } diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 0c0f75714c..41eab93bd1 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -33,6 +33,10 @@ const ALIASES: &[Alias] = &[ legacy_key: "web_search", feature: Feature::WebSearchRequest, }, + Alias { + legacy_key: "collab", + feature: Feature::Collab, + }, ]; pub(crate) fn legacy_feature_keys() -> impl Iterator { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e4c64d0eef..d858b29521 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -22,6 +22,7 @@ pub use codex_thread::ThreadConfigSnapshot; mod agent; mod codex_delegate; mod command_canonicalization; +mod commit_attribution; pub mod config; pub mod config_loader; pub mod connectors; @@ -42,6 +43,7 @@ pub mod landlock; pub mod mcp; mod mcp_connection_manager; pub mod models_manager; +mod network_policy_decision; pub mod network_proxy_loader; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index e2817d5a4d..0e6d61055e 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -432,6 +432,7 @@ mod tests { interface: None, dependencies: Some(SkillDependencies { tools }), policy: None, + permissions: None, path: PathBuf::from("skill"), scope: SkillScope::User, } diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index af2003375a..589be7aac2 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -89,6 +89,9 @@ pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); const CODEX_APPS_TOOLS_CACHE_TTL: Duration = Duration::from_secs(3600); +const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; +const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; +const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms"; /// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. /// MCP server/tool names are user-controlled, so sanitize the fully-qualified @@ -350,6 +353,10 @@ pub(crate) struct McpConnectionManager { } impl McpConnectionManager { + pub(crate) fn has_servers(&self) -> bool { + !self.clients.is_empty() + } + pub async fn initialize( &mut self, mcp_servers: &HashMap, @@ -905,6 +912,16 @@ pub(crate) fn filter_codex_apps_mcp_tools_only( .collect() } +pub(crate) fn filter_non_codex_apps_mcp_tools_only( + mcp_tools: &HashMap, +) -> HashMap { + mcp_tools + .iter() + .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect() +} + pub(crate) fn filter_mcp_tools_by_name( mcp_tools: &HashMap, selected_tools: &[String], @@ -1104,15 +1121,42 @@ async fn list_tools_for_client( client: &Arc, timeout: Option, ) -> Result> { + let total_start = Instant::now(); if server_name == CODEX_APPS_MCP_SERVER_NAME && let Some(cached_tools) = read_cached_codex_apps_tools() { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "hit")], + ); return Ok(cached_tools); } + let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached(server_name, client, timeout).await?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + let cache_write_start = Instant::now(); write_cached_codex_apps_tools(&tools); + emit_duration( + MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, + cache_write_start.elapsed(), + &[], + ); + } + + if server_name == CODEX_APPS_MCP_SERVER_NAME { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "miss")], + ); } Ok(tools) } @@ -1148,6 +1192,12 @@ fn write_cached_codex_apps_tools(tools: &[ToolInfo]) { }); } +fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { + if let Some(metrics) = codex_otel::metrics::global() { + let _ = metrics.record_duration(metric, duration, tags); + } +} + async fn list_tools_for_client_uncached( server_name: &str, client: &Arc, diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index 17e0359c98..146dc817ff 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -198,7 +198,7 @@ async fn build_request_context(session: &Arc, config: &Config) -> Reque let turn_context = session.new_default_turn().await; RequestContext::from_turn_context( turn_context.as_ref(), - turn_context.current_turn_metadata_header(), + turn_context.turn_metadata_state.current_header_value(), model, ) } diff --git a/codex-rs/core/src/memories/storage.rs b/codex-rs/core/src/memories/storage.rs index a596ea6f0c..641bae5e35 100644 --- a/codex-rs/core/src/memories/storage.rs +++ b/codex-rs/core/src/memories/storage.rs @@ -1,5 +1,5 @@ use codex_state::Stage1Output; -use std::collections::BTreeSet; +use std::collections::HashSet; use std::fmt::Write as _; use std::path::Path; use tracing::warn; @@ -8,8 +8,6 @@ use crate::memories::ensure_layout; use crate::memories::raw_memories_file; use crate::memories::rollout_summaries_dir; -//TODO(jif) clean. - /// Rebuild `raw_memories.md` from DB-backed stage-1 outputs. pub(super) async fn rebuild_raw_memories_file_from_memories( root: &Path, @@ -28,17 +26,14 @@ pub(super) async fn sync_rollout_summaries_from_memories( ) -> std::io::Result<()> { ensure_layout(root).await?; - let retained = memories - .iter() - .take(max_raw_memories_for_global) - .collect::>(); + let retained = retained_memories(memories, max_raw_memories_for_global); let keep = retained .iter() - .map(|memory| rollout_summary_file_stem(memory)) - .collect::>(); + .map(rollout_summary_file_stem) + .collect::>(); prune_rollout_summaries(root, &keep).await?; - for memory in &retained { + for memory in retained { write_rollout_summary_for_thread(root, memory).await?; } @@ -68,10 +63,7 @@ async fn rebuild_raw_memories_file( memories: &[Stage1Output], max_raw_memories_for_global: usize, ) -> std::io::Result<()> { - let retained = memories - .iter() - .take(max_raw_memories_for_global) - .collect::>(); + let retained = retained_memories(memories, max_raw_memories_for_global); let mut body = String::from("# Raw Memories\n\n"); if retained.is_empty() { @@ -81,18 +73,15 @@ async fn rebuild_raw_memories_file( body.push_str("Merged stage-1 raw memories (latest first):\n\n"); for memory in retained { - writeln!(body, "## Thread `{}`", memory.thread_id) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; + writeln!(body, "## Thread `{}`", memory.thread_id).map_err(raw_memories_format_error)?; writeln!( body, "updated_at: {}", memory.source_updated_at.to_rfc3339() ) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; - writeln!(body, "cwd: {}", memory.cwd.display()) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; - writeln!(body) - .map_err(|err| std::io::Error::other(format!("format raw memories: {err}")))?; + .map_err(raw_memories_format_error)?; + writeln!(body, "cwd: {}", memory.cwd.display()).map_err(raw_memories_format_error)?; + writeln!(body).map_err(raw_memories_format_error)?; body.push_str(memory.raw_memory.trim()); body.push_str("\n\n"); } @@ -100,7 +89,7 @@ async fn rebuild_raw_memories_file( tokio::fs::write(raw_memories_file(root), body).await } -async fn prune_rollout_summaries(root: &Path, keep: &BTreeSet) -> std::io::Result<()> { +async fn prune_rollout_summaries(root: &Path, keep: &HashSet) -> std::io::Result<()> { let dir_path = rollout_summaries_dir(root); let mut dir = match tokio::fs::read_dir(&dir_path).await { Ok(dir) => dir, @@ -138,24 +127,36 @@ async fn write_rollout_summary_for_thread( let path = rollout_summaries_dir(root).join(format!("{file_stem}.md")); let mut body = String::new(); - writeln!(body, "thread_id: {}", memory.thread_id) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; + writeln!(body, "thread_id: {}", memory.thread_id).map_err(rollout_summary_format_error)?; writeln!( body, "updated_at: {}", memory.source_updated_at.to_rfc3339() ) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; - writeln!(body, "cwd: {}", memory.cwd.display()) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; - writeln!(body) - .map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?; + .map_err(rollout_summary_format_error)?; + writeln!(body, "cwd: {}", memory.cwd.display()).map_err(rollout_summary_format_error)?; + writeln!(body).map_err(rollout_summary_format_error)?; body.push_str(&memory.rollout_summary); body.push('\n'); tokio::fs::write(path, body).await } +fn retained_memories( + memories: &[Stage1Output], + max_raw_memories_for_global: usize, +) -> &[Stage1Output] { + &memories[..memories.len().min(max_raw_memories_for_global)] +} + +fn raw_memories_format_error(err: std::fmt::Error) -> std::io::Error { + std::io::Error::other(format!("format raw memories: {err}")) +} + +fn rollout_summary_format_error(err: std::fmt::Error) -> std::io::Error { + std::io::Error::other(format!("format rollout summary: {err}")) +} + fn rollout_summary_file_stem(memory: &Stage1Output) -> String { const ROLLOUT_SLUG_MAX_LEN: usize = 20; diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 15b3dfe0d0..457351995f 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -7,7 +7,6 @@ use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result as CoreResult; -use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use crate::models_manager::model_info; @@ -74,18 +73,11 @@ impl ModelsManager { /// List all available models, refreshing according to the specified strategy. /// /// Returns model presets sorted by priority and filtered by auth mode and visibility. - pub async fn list_models( - &self, - config: &Config, - refresh_strategy: RefreshStrategy, - ) -> Vec { - if let Err(err) = self - .refresh_available_models(config, refresh_strategy) - .await - { + pub async fn list_models(&self, refresh_strategy: RefreshStrategy) -> Vec { + if let Err(err) = self.refresh_available_models(refresh_strategy).await { error!("failed to refresh available models: {err}"); } - let remote_models = self.get_remote_models(config).await; + let remote_models = self.get_remote_models().await; self.build_available_models(remote_models) } @@ -99,8 +91,8 @@ impl ModelsManager { /// Attempt to list models without blocking, using the current cached state. /// /// Returns an error if the internal lock cannot be acquired. - pub fn try_list_models(&self, config: &Config) -> Result, TryLockError> { - let remote_models = self.try_get_remote_models(config)?; + pub fn try_list_models(&self) -> Result, TryLockError> { + let remote_models = self.try_get_remote_models()?; Ok(self.build_available_models(remote_models)) } @@ -112,19 +104,15 @@ impl ModelsManager { pub async fn get_default_model( &self, model: &Option, - config: &Config, refresh_strategy: RefreshStrategy, ) -> String { if let Some(model) = model.as_ref() { return model.to_string(); } - if let Err(err) = self - .refresh_available_models(config, refresh_strategy) - .await - { + if let Err(err) = self.refresh_available_models(refresh_strategy).await { error!("failed to refresh available models: {err}"); } - let remote_models = self.get_remote_models(config).await; + let remote_models = self.get_remote_models().await; let available = self.build_available_models(remote_models); available .iter() @@ -137,27 +125,22 @@ impl ModelsManager { // todo(aibrahim): look if we can tighten it to pub(crate) /// Look up model metadata, applying remote overrides and config adjustments. pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo { - let remote = self - .find_remote_model_by_longest_prefix(model, config) - .await; - let model = if let Some(remote) = remote { + let remote = self.find_remote_model_by_longest_prefix(model).await; + let model_info = if let Some(remote) = remote { ModelInfo { slug: model.to_string(), + used_fallback_model_metadata: false, ..remote } } else { model_info::model_info_from_slug(model) }; - model_info::with_config_overrides(model, config) + model_info::with_config_overrides(model_info, config) } - async fn find_remote_model_by_longest_prefix( - &self, - model: &str, - config: &Config, - ) -> Option { + async fn find_remote_model_by_longest_prefix(&self, model: &str) -> Option { let mut best: Option = None; - for candidate in self.get_remote_models(config).await { + for candidate in self.get_remote_models().await { if !model.starts_with(&candidate.slug) { continue; } @@ -176,7 +159,7 @@ impl ModelsManager { /// Refresh models if the provided ETag differs from the cached ETag. /// /// Uses `Online` strategy to fetch latest models when ETags differ. - pub(crate) async fn refresh_if_new_etag(&self, etag: String, config: &Config) { + pub(crate) async fn refresh_if_new_etag(&self, etag: String) { let current_etag = self.get_etag().await; if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) { if let Err(err) = self.cache_manager.renew_cache_ttl().await { @@ -184,23 +167,13 @@ impl ModelsManager { } return; } - if let Err(err) = self - .refresh_available_models(config, RefreshStrategy::Online) - .await - { + if let Err(err) = self.refresh_available_models(RefreshStrategy::Online).await { error!("failed to refresh available models: {err}"); } } /// Refresh available models according to the specified strategy. - async fn refresh_available_models( - &self, - config: &Config, - refresh_strategy: RefreshStrategy, - ) -> CoreResult<()> { - if !config.features.enabled(Feature::RemoteModels) { - return Ok(()); - } + async fn refresh_available_models(&self, refresh_strategy: RefreshStrategy) -> CoreResult<()> { if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) { if matches!( refresh_strategy, @@ -335,20 +308,12 @@ impl ModelsManager { merged_presets } - async fn get_remote_models(&self, config: &Config) -> Vec { - if config.features.enabled(Feature::RemoteModels) { - self.remote_models.read().await.clone() - } else { - Vec::new() - } + async fn get_remote_models(&self) -> Vec { + self.remote_models.read().await.clone() } - fn try_get_remote_models(&self, config: &Config) -> Result, TryLockError> { - if config.features.enabled(Feature::RemoteModels) { - Ok(self.remote_models.try_read()?.clone()) - } else { - Ok(Vec::new()) - } + fn try_get_remote_models(&self) -> Result, TryLockError> { + Ok(self.remote_models.try_read()?.clone()) } /// Construct a manager with a specific provider for testing. @@ -398,7 +363,6 @@ mod tests { use crate::CodexAuth; use crate::auth::AuthCredentialsStoreMode; use crate::config::ConfigBuilder; - use crate::features::Feature; use crate::model_provider_info::WireApi; use chrono::Utc; use codex_protocol::openai_models::ModelsResponse; @@ -472,6 +436,36 @@ mod tests { } } + #[tokio::test] + async fn get_model_info_tracks_fallback_usage() { + let codex_home = tempdir().expect("temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("load default test config"); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new(codex_home.path().to_path_buf(), auth_manager); + let known_slug = manager + .get_remote_models() + .await + .first() + .expect("bundled models should include at least one model") + .slug + .clone(); + + let known = manager.get_model_info(known_slug.as_str(), &config).await; + assert!(!known.used_fallback_model_metadata); + assert_eq!(known.slug, known_slug); + + let unknown = manager + .get_model_info("model-that-does-not-exist", &config) + .await; + assert!(unknown.used_fallback_model_metadata); + assert_eq!(unknown.slug, "model-that-does-not-exist"); + } + #[tokio::test] async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; @@ -488,12 +482,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -504,15 +492,13 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("refresh succeeds"); - let cached_remote = manager.get_remote_models(&config).await; + let cached_remote = manager.get_remote_models().await; assert_models_contain(&cached_remote, &remote_models); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let high_idx = available .iter() .position(|model| model.model == "priority-high") @@ -545,12 +531,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -561,17 +541,17 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("first refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); + assert_models_contain(&manager.get_remote_models().await, &remote_models); // Second call should read from cache and avoid the network. manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("cached refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); + assert_models_contain(&manager.get_remote_models().await, &remote_models); assert_eq!( models_mock.requests().len(), 1, @@ -592,12 +572,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -608,7 +582,7 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("initial refresh succeeds"); @@ -632,10 +606,10 @@ mod tests { .await; manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); + assert_models_contain(&manager.get_remote_models().await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, @@ -661,12 +635,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -677,7 +645,7 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("initial refresh succeeds"); @@ -701,10 +669,10 @@ mod tests { .await; manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); + assert_models_contain(&manager.get_remote_models().await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, @@ -730,12 +698,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let provider = provider_for(server.uri()); @@ -747,7 +709,7 @@ mod tests { manager.cache_manager.set_ttl(Duration::ZERO); manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("initial refresh succeeds"); @@ -762,12 +724,12 @@ mod tests { .await; manager - .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) + .refresh_available_models(RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); let available = manager - .try_list_models(&config) + .try_list_models() .expect("models should be available"); assert!( available.iter().any(|preset| preset.model == "remote-new"), @@ -802,12 +764,6 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load default test config"); - config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, @@ -821,10 +777,10 @@ mod tests { ); manager - .refresh_available_models(&config, RefreshStrategy::Online) + .refresh_available_models(RefreshStrategy::Online) .await .expect("refresh should no-op without chatgpt auth"); - let cached_remote = manager.get_remote_models(&config).await; + let cached_remote = manager.get_remote_models().await; assert!( !cached_remote .iter() diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 6a29cd96f9..19a945f951 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -20,8 +20,10 @@ const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective so const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> ModelInfo { - if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries { - model.supports_reasoning_summaries = supports_reasoning_summaries; + if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries + && supports_reasoning_summaries + { + model.supports_reasoning_summaries = true; } if let Some(context_window) = config.model_context_window { model.context_window = Some(context_window); @@ -81,6 +83,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: true, // this is the fallback model metadata } } @@ -99,3 +102,46 @@ fn local_personality_messages_for_slug(slug: &str) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::test_config; + use pretty_assertions::assert_eq; + + #[test] + fn reasoning_summaries_override_true_enables_support() { + let model = model_info_from_slug("unknown-model"); + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(true); + + let updated = with_config_overrides(model.clone(), &config); + let mut expected = model; + expected.supports_reasoning_summaries = true; + + assert_eq!(updated, expected); + } + + #[test] + fn reasoning_summaries_override_false_does_not_disable_support() { + let mut model = model_info_from_slug("unknown-model"); + model.supports_reasoning_summaries = true; + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(false); + + let updated = with_config_overrides(model.clone(), &config); + + assert_eq!(updated, model); + } + + #[test] + fn reasoning_summaries_override_false_is_noop_when_model_is_false() { + let model = model_info_from_slug("unknown-model"); + let mut config = test_config(); + config.model_supports_reasoning_summaries = Some(false); + + let updated = with_config_overrides(model.clone(), &config); + + assert_eq!(updated, model); + } +} diff --git a/codex-rs/core/src/network_policy_decision.rs b/codex-rs/core/src/network_policy_decision.rs new file mode 100644 index 0000000000..3da6d34d6c --- /dev/null +++ b/codex-rs/core/src/network_policy_decision.rs @@ -0,0 +1,254 @@ +use codex_network_proxy::BlockedRequest; +use codex_network_proxy::NetworkDecisionSource; +use codex_network_proxy::NetworkPolicyDecision; +use codex_protocol::approvals::NetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NetworkPolicyDecisionPayload { + pub decision: NetworkPolicyDecision, + pub source: NetworkDecisionSource, + #[serde(default)] + pub protocol: Option, + pub host: Option, + pub reason: Option, + pub port: Option, +} + +impl NetworkPolicyDecisionPayload { + pub(crate) fn is_ask_from_decider(&self) -> bool { + self.decision == NetworkPolicyDecision::Ask && self.source == NetworkDecisionSource::Decider + } +} + +fn parse_network_policy_decision(value: &str) -> Option { + match value { + "deny" => Some(NetworkPolicyDecision::Deny), + "ask" => Some(NetworkPolicyDecision::Ask), + _ => None, + } +} + +pub(crate) fn network_approval_context_from_payload( + payload: &NetworkPolicyDecisionPayload, +) -> Option { + if !payload.is_ask_from_decider() { + return None; + } + + let protocol = payload.protocol?; + + let host = payload.host.as_deref()?.trim(); + if host.is_empty() { + return None; + } + + Some(NetworkApprovalContext { + host: host.to_string(), + protocol, + }) +} + +pub(crate) fn denied_network_policy_message(blocked: &BlockedRequest) -> Option { + let decision = blocked + .decision + .as_deref() + .and_then(parse_network_policy_decision); + if decision != Some(NetworkPolicyDecision::Deny) { + return None; + } + + let host = blocked.host.trim(); + if host.is_empty() { + return Some("Network access was blocked by policy.".to_string()); + } + + let detail = match blocked.reason.as_str() { + "denied" => "domain is explicitly denied by policy and cannot be approved from this prompt", + "not_allowed" => "domain is not on the allowlist for the current sandbox mode", + "not_allowed_local" => "local/private network addresses are blocked by policy", + "method_not_allowed" => "request method is blocked by the current network mode", + "proxy_disabled" => "managed network proxy is disabled", + _ => "request is blocked by network policy", + }; + + Some(format!( + "Network access to \"{host}\" was blocked: {detail}." + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_network_proxy::BlockedRequest; + use pretty_assertions::assert_eq; + + #[test] + fn network_approval_context_requires_ask_from_decider() { + let payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Deny, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + + assert_eq!(network_approval_context_from_payload(&payload), None); + } + + #[test] + fn network_approval_context_maps_http_https_and_socks_protocols() { + let http_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Http), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(80), + }; + assert_eq!( + network_approval_context_from_payload(&http_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Http, + }) + ); + + let https_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&https_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }) + ); + + let http_connect_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Https), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&http_connect_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }) + ); + + let socks5_tcp_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Socks5Tcp), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&socks5_tcp_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Socks5Tcp, + }) + ); + + let socks5_udp_payload = NetworkPolicyDecisionPayload { + decision: NetworkPolicyDecision::Ask, + source: NetworkDecisionSource::Decider, + protocol: Some(NetworkApprovalProtocol::Socks5Udp), + host: Some("example.com".to_string()), + reason: Some("not_allowed".to_string()), + port: Some(443), + }; + assert_eq!( + network_approval_context_from_payload(&socks5_udp_payload), + Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Socks5Udp, + }) + ); + } + + #[test] + fn network_policy_decision_payload_deserializes_proxy_protocol_aliases() { + let payload: NetworkPolicyDecisionPayload = serde_json::from_str( + r#"{ + "decision":"ask", + "source":"decider", + "protocol":"https_connect", + "host":"example.com", + "reason":"not_allowed", + "port":443 + }"#, + ) + .expect("payload should deserialize"); + assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); + + let payload: NetworkPolicyDecisionPayload = serde_json::from_str( + r#"{ + "decision":"ask", + "source":"decider", + "protocol":"http-connect", + "host":"example.com", + "reason":"not_allowed", + "port":443 + }"#, + ) + .expect("payload should deserialize"); + assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https)); + } + + #[test] + fn denied_network_policy_message_requires_deny_decision() { + let blocked = BlockedRequest { + host: "example.com".to_string(), + reason: "not_allowed".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("ask".to_string()), + source: Some("decider".to_string()), + port: Some(80), + timestamp: 0, + }; + assert_eq!(denied_network_policy_message(&blocked), None); + } + + #[test] + fn denied_network_policy_message_for_denylist_block_is_explicit() { + let blocked = BlockedRequest { + host: "example.com".to_string(), + reason: "denied".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("deny".to_string()), + source: Some("baseline_policy".to_string()), + port: Some(80), + timestamp: 0, + }; + assert_eq!( + denied_network_policy_message(&blocked), + Some( + "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() + ) + ); + } +} diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index dc3e111a67..f83433efb2 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -122,6 +122,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeEnd(_) => Some(EventPersistenceMode::Extended), EventMsg::Warning(_) + | EventMsg::ModelReroute(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/core/src/sandbox_tags.rs b/codex-rs/core/src/sandbox_tags.rs index d81647e711..56c9e0f184 100644 --- a/codex-rs/core/src/sandbox_tags.rs +++ b/codex-rs/core/src/sandbox_tags.rs @@ -6,6 +6,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; pub(crate) fn sandbox_tag( policy: &SandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, + use_linux_sandbox_bwrap: bool, ) -> &'static str { if matches!(policy, SandboxPolicy::DangerFullAccess) { return "none"; @@ -17,8 +18,61 @@ pub(crate) fn sandbox_tag( { return "windows_elevated"; } + if cfg!(target_os = "linux") && use_linux_sandbox_bwrap { + return "linux_bubblewrap"; + } get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) .map(SandboxType::as_metric_tag) .unwrap_or("none") } + +#[cfg(test)] +mod tests { + use super::sandbox_tag; + use crate::exec::SandboxType; + use crate::protocol::SandboxPolicy; + use crate::safety::get_platform_sandbox; + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::protocol::NetworkAccess; + use pretty_assertions::assert_eq; + + #[test] + fn danger_full_access_is_untagged_even_when_bubblewrap_is_enabled() { + let actual = sandbox_tag( + &SandboxPolicy::DangerFullAccess, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(actual, "none"); + } + + #[test] + fn external_sandbox_keeps_external_tag_when_bubblewrap_is_enabled() { + let actual = sandbox_tag( + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(actual, "external"); + } + + #[test] + fn bubblewrap_feature_sets_distinct_linux_tag() { + let actual = sandbox_tag( + &SandboxPolicy::new_read_only_policy(), + WindowsSandboxLevel::Disabled, + true, + ); + let expected = if cfg!(target_os = "linux") { + "linux_bubblewrap" + } else { + get_platform_sandbox(false) + .map(SandboxType::as_metric_tag) + .unwrap_or("none") + }; + assert_eq!(actual, expected); + } +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 9a72b6e249..487278b619 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -46,6 +46,7 @@ pub struct ExecRequest { pub cwd: PathBuf, pub env: HashMap, pub network: Option, + pub network_attempt_id: Option, pub expiration: ExecExpiration, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, @@ -214,6 +215,7 @@ impl SandboxManager { cwd: spec.cwd, env, network: network.cloned(), + network_attempt_id: None, expiration: spec.expiration, sandbox, windows_sandbox_level, diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 29c05e0183..7f738797b8 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -14,7 +14,6 @@ use tokio::process::Child; use url::Url; use crate::protocol::SandboxPolicy; -use crate::seatbelt_permissions::MacOsPreferencesPermission; use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; use crate::seatbelt_permissions::build_seatbelt_extensions; use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -299,10 +298,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions( let seatbelt_extensions = extensions.map_or_else( || { // Backward-compatibility default when no extension profile is provided. - build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - ..Default::default() - }) + build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) }, build_seatbelt_extensions, ); @@ -480,7 +476,7 @@ mod tests { } #[test] - fn seatbelt_args_omit_macos_extensions_when_profile_is_empty() { + fn seatbelt_args_default_extension_profile_keeps_preferences_read_access() { let cwd = std::env::temp_dir(); let args = create_seatbelt_command_args_with_extensions( vec!["echo".to_string(), "ok".to_string()], @@ -494,7 +490,7 @@ mod tests { assert!(!policy.contains("appleevent-send")); assert!(!policy.contains("com.apple.axserver")); assert!(!policy.contains("com.apple.CalendarAgent")); - assert!(!policy.contains("user-preference-read")); + assert!(policy.contains("(allow user-preference-read)")); assert!(!policy.contains("user-preference-write")); } diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/core/src/seatbelt_permissions.rs index 29313d7a3c..aacc3a7784 100644 --- a/codex-rs/core/src/seatbelt_permissions.rs +++ b/codex-rs/core/src/seatbelt_permissions.rs @@ -6,10 +6,12 @@ use std::path::PathBuf; #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum MacOsPreferencesPermission { + // IMPORTANT: ReadOnly needs to be the default because it's the security-sensitive default. + // it's important for allowing cf prefs to work. #[default] - None, ReadOnly, ReadWrite, + None, } #[allow(dead_code)] @@ -164,7 +166,6 @@ mod tests { use super::MacOsPreferencesPermission; use super::MacOsSeatbeltProfileExtensions; use super::build_seatbelt_extensions; - use pretty_assertions::assert_eq; #[test] fn preferences_read_only_emits_read_clauses_only() { @@ -239,8 +240,9 @@ mod tests { } #[test] - fn empty_extensions_emit_empty_policy() { + fn default_extensions_emit_preferences_read_only_policy() { let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()); - assert_eq!(policy.policy, ""); + assert!(policy.policy.contains("(allow user-preference-read)")); + assert!(!policy.policy.contains("(allow user-preference-write)")); } } diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index 660785d712..4dec3fe399 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -476,6 +476,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permissions: None, path: PathBuf::from(path), scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 6821d1b8fe..e00bd42976 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::config::Permissions; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::default_project_root_markers; @@ -11,6 +12,8 @@ use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; use crate::skills::model::SkillPolicy; use crate::skills::model::SkillToolDependency; +use crate::skills::permissions::SkillManifestPermissions; +use crate::skills::permissions::compile_permission_profile; use crate::skills::system::system_cache_root_dir; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::SkillScope; @@ -50,6 +53,8 @@ struct SkillMetadataFile { dependencies: Option, #[serde(default)] policy: Option, + #[serde(default)] + permissions: Option, } #[derive(Debug, Default, Deserialize)] @@ -490,7 +495,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result, Option, Option, + Option, ) { // Fail open: optional metadata should not block loading SKILL.md. let Some(skill_dir) = skill_path.parent() else { - return (None, None, None); + return (None, None, None, None); }; let metadata_path = skill_dir .join(SKILLS_METADATA_DIR) .join(SKILLS_METADATA_FILENAME); if !metadata_path.exists() { - return (None, None, None); + return (None, None, None, None); } let contents = match fs::read_to_string(&metadata_path) { @@ -542,7 +549,7 @@ fn load_skill_metadata( path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); - return (None, None, None); + return (None, None, None, None); } }; @@ -554,7 +561,7 @@ fn load_skill_metadata( path = metadata_path.display(), label = SKILLS_METADATA_FILENAME ); - return (None, None, None); + return (None, None, None, None); } }; @@ -562,12 +569,14 @@ fn load_skill_metadata( interface, dependencies, policy, + permissions, } = parsed; ( resolve_interface(interface, skill_dir), resolve_dependencies(dependencies), resolve_policy(policy), + compile_permission_profile(skill_dir, permissions), ) } @@ -801,7 +810,9 @@ mod tests { use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::config::Constrained; use crate::config::ProjectConfig; + use crate::config::types::ShellEnvironmentPolicy; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; @@ -1021,6 +1032,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1168,6 +1180,7 @@ mod tests { ], }), policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1223,6 +1236,7 @@ interface: }), dependencies: None, policy: None, + permissions: None, path: normalized(skill_path.as_path()), scope: SkillScope::User, }] @@ -1295,6 +1309,219 @@ policy: {} ); } + #[tokio::test] + async fn loads_skill_permissions_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-skill", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + fs::create_dir_all(skill_dir.join("data")).expect("create read path"); + fs::create_dir_all(skill_dir.join("output")).expect("create write path"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + network: true + file_system: + read: + - "./data" + - "./data" + write: + - "./output" +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + #[cfg(target_os = "macos")] + let macos_seatbelt_profile_extensions = + Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default()); + #[cfg(not(target_os = "macos"))] + let macos_seatbelt_profile_extensions = None; + assert_eq!( + outcome.skills[0].permissions, + Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(normalized( + skill_dir.join("output").as_path(), + )) + .expect("absolute output path") + ], + read_only_access: crate::protocol::ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from(normalized( + skill_dir.join("data").as_path(), + )) + .expect("absolute data path") + ], + }, + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions, + }) + ); + } + + #[tokio::test] + async fn empty_skill_permissions_do_not_create_profile() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-empty", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: {} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + #[cfg(target_os = "macos")] + let expected = Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::new_read_only_policy(), + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + }); + #[cfg(not(target_os = "macos"))] + let expected = Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::new_read_only_policy(), + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }); + assert_eq!(outcome.skills[0].permissions, expected); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn loads_skill_macos_permissions_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + macos: + preferences: "readwrite" + automations: + - "com.apple.Notes" + accessibility: true + calendar: true +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + let profile = outcome.skills[0] + .permissions + .as_ref() + .expect("permission profile"); + assert_eq!( + profile.macos_seatbelt_profile_extensions, + Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions { + macos_preferences: + crate::seatbelt_permissions::MacOsPreferencesPermission::ReadWrite, + macos_automation: + crate::seatbelt_permissions::MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string() + ],), + macos_accessibility: true, + macos_calendar: true, + } + ) + ); + } + + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn loads_skill_macos_permissions_from_yaml_non_macos_does_not_create_profile() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +permissions: + macos: + preferences: "readwrite" + automations: + - "com.apple.Notes" + accessibility: true + calendar: true +"#, + ); + + let cfg = make_config(&codex_home).await; + 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].permissions, + Some(Permissions { + approval_policy: Constrained::allow_any(crate::protocol::AskForApproval::Never), + sandbox_policy: Constrained::allow_any( + crate::protocol::SandboxPolicy::new_read_only_policy(), + ), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }) + ); + } + #[tokio::test] async fn accepts_icon_paths_under_assets_dir() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -1339,6 +1566,7 @@ policy: {} }), dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1379,6 +1607,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1432,6 +1661,7 @@ policy: {} }), dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1473,6 +1703,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1517,6 +1748,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&shared_skill_path), scope: SkillScope::User, }] @@ -1577,6 +1809,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1613,6 +1846,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&shared_skill_path), scope: SkillScope::Admin, }] @@ -1653,6 +1887,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&linked_skill_path), scope: SkillScope::Repo, }] @@ -1720,6 +1955,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&within_depth_path), scope: SkillScope::User, }] @@ -1747,6 +1983,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1778,6 +2015,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::User, }] @@ -1890,6 +2128,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1925,6 +2164,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1978,6 +2218,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&nested_skill_path), scope: SkillScope::Repo, }, @@ -1988,6 +2229,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&root_skill_path), scope: SkillScope::Repo, }, @@ -2027,6 +2269,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2064,6 +2307,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2105,6 +2349,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&repo_skill_path), scope: SkillScope::Repo, }, @@ -2115,6 +2360,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&user_skill_path), scope: SkillScope::User, }, @@ -2179,6 +2425,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: first_path, scope: SkillScope::Repo, }, @@ -2189,6 +2436,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: second_path, scope: SkillScope::Repo, }, @@ -2260,6 +2508,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2318,6 +2567,7 @@ policy: {} interface: None, dependencies: None, policy: None, + permissions: None, path: normalized(&skill_path), scope: SkillScope::System, }] diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 17405129ec..18f7180f99 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -3,6 +3,7 @@ pub mod injection; pub mod loader; pub mod manager; pub mod model; +pub mod permissions; pub mod remote; pub mod render; pub mod system; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index bc75b7e365..ac642db43d 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -1,9 +1,10 @@ use std::collections::HashSet; use std::path::PathBuf; +use crate::config::Permissions; use codex_protocol::protocol::SkillScope; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SkillMetadata { pub name: String, pub description: String, @@ -11,6 +12,8 @@ pub struct SkillMetadata { pub interface: Option, pub dependencies: Option, pub policy: Option, + // This is an experimental field. + pub permissions: Option, pub path: PathBuf, pub scope: SkillScope, } diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs new file mode 100644 index 0000000000..1cf18e1052 --- /dev/null +++ b/codex-rs/core/src/skills/permissions.rs @@ -0,0 +1,510 @@ +use std::collections::HashSet; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; +use dirs::home_dir; +use dunce::canonicalize as canonicalize_path; +use serde::Deserialize; +use tracing::warn; + +use crate::config::Constrained; +use crate::config::Permissions; +use crate::config::types::ShellEnvironmentPolicy; +use crate::protocol::AskForApproval; +use crate::protocol::ReadOnlyAccess; +use crate::protocol::SandboxPolicy; +#[cfg(target_os = "macos")] +use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; +#[cfg(not(target_os = "macos"))] +type MacOsSeatbeltProfileExtensions = (); + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub(crate) struct SkillManifestPermissions { + #[serde(default)] + pub(crate) network: bool, + #[serde(default)] + pub(crate) file_system: SkillManifestFileSystemPermissions, + #[serde(default)] + pub(crate) macos: SkillManifestMacOsPermissions, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub(crate) struct SkillManifestFileSystemPermissions { + #[serde(default)] + pub(crate) read: Vec, + #[serde(default)] + pub(crate) write: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub(crate) struct SkillManifestMacOsPermissions { + #[serde(default)] + pub(crate) preferences: Option, + #[serde(default)] + pub(crate) automations: Option, + #[serde(default)] + pub(crate) accessibility: bool, + #[serde(default)] + pub(crate) calendar: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub(crate) enum MacOsPreferencesValue { + Bool(bool), + Mode(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub(crate) enum MacOsAutomationValue { + Bool(bool), + BundleIds(Vec), +} + +pub(crate) fn compile_permission_profile( + skill_dir: &Path, + permissions: Option, +) -> Option { + let permissions = permissions?; + let fs_read = normalize_permission_paths( + skill_dir, + &permissions.file_system.read, + "permissions.file_system.read", + ); + let fs_write = normalize_permission_paths( + skill_dir, + &permissions.file_system.write, + "permissions.file_system.write", + ); + let sandbox_policy = if !fs_write.is_empty() { + SandboxPolicy::WorkspaceWrite { + writable_roots: fs_write, + read_only_access: if fs_read.is_empty() { + ReadOnlyAccess::FullAccess + } else { + ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: fs_read, + } + }, + network_access: permissions.network, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + } else if !fs_read.is_empty() { + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: fs_read, + }, + } + } else { + // Default sandbox policy + SandboxPolicy::new_read_only_policy() + }; + let macos_seatbelt_profile_extensions = + build_macos_seatbelt_profile_extensions(&permissions.macos); + + Some(Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(sandbox_policy), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions, + }) +} + +fn normalize_permission_paths( + skill_dir: &Path, + values: &[String], + field: &str, +) -> Vec { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + for value in values { + let Some(path) = normalize_permission_path(skill_dir, value, field) else { + continue; + }; + if seen.insert(path.clone()) { + paths.push(path); + } + } + + paths +} + +fn normalize_permission_path( + skill_dir: &Path, + value: &str, + field: &str, +) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + warn!("ignoring {field}: value is empty"); + return None; + } + + let expanded = expand_home(trimmed); + let path = PathBuf::from(expanded); + let absolute = if path.is_absolute() { + path + } else { + skill_dir.join(path) + }; + let normalized = normalize_lexically(&absolute); + let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized); + match AbsolutePathBuf::from_absolute_path(&canonicalized) { + Ok(path) => Some(path), + Err(error) => { + warn!("ignoring {field}: expected absolute path, got {canonicalized:?}: {error}"); + None + } + } +} + +fn expand_home(path: &str) -> String { + if path == "~" { + if let Some(home) = home_dir() { + return home.to_string_lossy().to_string(); + } + return path.to_string(); + } + if let Some(rest) = path.strip_prefix("~/") + && let Some(home) = home_dir() + { + return home.join(rest).to_string_lossy().to_string(); + } + path.to_string() +} + +#[cfg(target_os = "macos")] +fn build_macos_seatbelt_profile_extensions( + permissions: &SkillManifestMacOsPermissions, +) -> Option { + let defaults = MacOsSeatbeltProfileExtensions::default(); + + let extensions = MacOsSeatbeltProfileExtensions { + macos_preferences: resolve_macos_preferences_permission( + permissions.preferences.as_ref(), + defaults.macos_preferences, + ), + macos_automation: resolve_macos_automation_permission( + permissions.automations.as_ref(), + defaults.macos_automation, + ), + macos_accessibility: permissions.accessibility, + macos_calendar: permissions.calendar, + }; + Some(extensions) +} + +#[cfg(target_os = "macos")] +fn resolve_macos_preferences_permission( + value: Option<&MacOsPreferencesValue>, + default: crate::seatbelt_permissions::MacOsPreferencesPermission, +) -> crate::seatbelt_permissions::MacOsPreferencesPermission { + use crate::seatbelt_permissions::MacOsPreferencesPermission; + + match value { + Some(MacOsPreferencesValue::Bool(true)) => MacOsPreferencesPermission::ReadOnly, + Some(MacOsPreferencesValue::Bool(false)) => MacOsPreferencesPermission::None, + Some(MacOsPreferencesValue::Mode(mode)) => { + let mode = mode.trim(); + if mode.eq_ignore_ascii_case("readonly") || mode.eq_ignore_ascii_case("read-only") { + MacOsPreferencesPermission::ReadOnly + } else if mode.eq_ignore_ascii_case("readwrite") + || mode.eq_ignore_ascii_case("read-write") + { + MacOsPreferencesPermission::ReadWrite + } else { + warn!( + "ignoring permissions.macos.preferences: expected true/false, readonly, or readwrite" + ); + default + } + } + None => default, + } +} + +#[cfg(target_os = "macos")] +fn resolve_macos_automation_permission( + value: Option<&MacOsAutomationValue>, + default: crate::seatbelt_permissions::MacOsAutomationPermission, +) -> crate::seatbelt_permissions::MacOsAutomationPermission { + use crate::seatbelt_permissions::MacOsAutomationPermission; + + match value { + Some(MacOsAutomationValue::Bool(true)) => MacOsAutomationPermission::All, + Some(MacOsAutomationValue::Bool(false)) => MacOsAutomationPermission::None, + Some(MacOsAutomationValue::BundleIds(bundle_ids)) => { + let bundle_ids = bundle_ids + .iter() + .map(|bundle_id| bundle_id.trim()) + .filter(|bundle_id| !bundle_id.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if bundle_ids.is_empty() { + MacOsAutomationPermission::None + } else { + MacOsAutomationPermission::BundleIds(bundle_ids) + } + } + None => default, + } +} + +#[cfg(not(target_os = "macos"))] +fn build_macos_seatbelt_profile_extensions( + _: &SkillManifestMacOsPermissions, +) -> Option { + None +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + +#[cfg(test)] +mod tests { + use super::SkillManifestFileSystemPermissions; + #[cfg(target_os = "macos")] + use super::SkillManifestMacOsPermissions; + use super::SkillManifestPermissions; + use super::compile_permission_profile; + use crate::config::Constrained; + use crate::config::Permissions; + use crate::config::types::ShellEnvironmentPolicy; + use crate::protocol::AskForApproval; + use crate::protocol::ReadOnlyAccess; + use crate::protocol::SandboxPolicy; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::fs; + + #[test] + fn compile_permission_profile_normalizes_paths() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(skill_dir.join("scripts")).expect("skill dir"); + let read_dir = skill_dir.join("data"); + fs::create_dir_all(&read_dir).expect("read dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + network: true, + file_system: SkillManifestFileSystemPermissions { + read: vec![ + "./data".to_string(), + "./data".to_string(), + "scripts/../data".to_string(), + ], + write: vec!["./output".to_string()], + }, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile, + Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(skill_dir.join("output")) + .expect("absolute output path") + ], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from( + dunce::canonicalize(&read_dir).unwrap_or(read_dir) + ) + .expect("absolute read path") + ], + }, + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: None, + } + ); + } + + #[test] + fn compile_permission_profile_without_permissions_has_empty_profile() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = compile_permission_profile(&skill_dir, None); + + assert_eq!(profile, None); + } + + #[test] + fn compile_permission_profile_with_network_only_uses_read_only_policy() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + network: true, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile, + Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: None, + } + ); + } + + #[test] + fn compile_permission_profile_with_network_and_read_only_paths_uses_read_only_policy() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + let read_dir = skill_dir.join("data"); + fs::create_dir_all(&read_dir).expect("read dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + network: true, + file_system: SkillManifestFileSystemPermissions { + read: vec!["./data".to_string()], + write: Vec::new(), + }, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile, + Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from( + dunce::canonicalize(&read_dir).unwrap_or(read_dir) + ) + .expect("absolute read path") + ], + }, + }), + network: None, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(), + ), + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: None, + } + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn compile_permission_profile_builds_macos_permission_file() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = compile_permission_profile( + &skill_dir, + Some(SkillManifestPermissions { + macos: SkillManifestMacOsPermissions { + preferences: Some(super::MacOsPreferencesValue::Mode("readwrite".to_string())), + automations: Some(super::MacOsAutomationValue::BundleIds(vec![ + "com.apple.Notes".to_string(), + ])), + accessibility: true, + calendar: true, + }, + ..Default::default() + }), + ) + .expect("profile"); + + assert_eq!( + profile.macos_seatbelt_profile_extensions, + Some( + crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions { + macos_preferences: + crate::seatbelt_permissions::MacOsPreferencesPermission::ReadWrite, + macos_automation: + crate::seatbelt_permissions::MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string() + ],), + macos_accessibility: true, + macos_calendar: true, + } + ) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn compile_permission_profile_uses_macos_defaults_when_values_missing() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_dir = tempdir.path().join("skill"); + fs::create_dir_all(&skill_dir).expect("skill dir"); + + let profile = + compile_permission_profile(&skill_dir, Some(SkillManifestPermissions::default())) + .expect("profile"); + + assert_eq!( + profile.macos_seatbelt_profile_extensions, + Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default()) + ); + } +} diff --git a/codex-rs/core/src/skills/remote.rs b/codex-rs/core/src/skills/remote.rs index af6dd5593a..6abd402584 100644 --- a/codex-rs/core/src/skills/remote.rs +++ b/codex-rs/core/src/skills/remote.rs @@ -1,18 +1,49 @@ use anyhow::Context; use anyhow::Result; use serde::Deserialize; -use std::collections::HashMap; -use std::collections::HashSet; use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use crate::auth::CodexAuth; use crate::config::Config; use crate::default_client::build_reqwest_client; +use codex_protocol::protocol::RemoteSkillHazelnutScope; +use codex_protocol::protocol::RemoteSkillProductSurface; const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); +fn as_query_hazelnut_scope(scope: RemoteSkillHazelnutScope) -> Option<&'static str> { + match scope { + RemoteSkillHazelnutScope::WorkspaceShared => Some("workspace-shared"), + RemoteSkillHazelnutScope::AllShared => Some("all-shared"), + RemoteSkillHazelnutScope::Personal => Some("personal"), + RemoteSkillHazelnutScope::Example => Some("example"), + } +} + +fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'static str { + match product_surface { + RemoteSkillProductSurface::Chatgpt => "chatgpt", + RemoteSkillProductSurface::Codex => "codex", + RemoteSkillProductSurface::Api => "api", + RemoteSkillProductSurface::Atlas => "atlas", + } +} + +fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> { + let Some(auth) = auth else { + anyhow::bail!("chatgpt authentication required for hazelnut scopes"); + }; + if !auth.is_chatgpt_auth() { + anyhow::bail!( + "chatgpt authentication required for hazelnut scopes; api key auth is not supported" + ); + } + Ok(auth) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteSkillSummary { pub id: String, @@ -20,27 +51,12 @@ pub struct RemoteSkillSummary { pub description: String, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RemoteSkillDownload { - pub id: String, - pub name: String, - pub base_sediment_id: String, - pub files: HashMap, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteSkillDownloadResult { pub id: String, - pub name: String, pub path: PathBuf, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RemoteSkillFileRange { - pub start: u64, - pub length: u64, -} - #[derive(Debug, Deserialize)] struct RemoteSkillsResponse { hazelnuts: Vec, @@ -53,36 +69,40 @@ struct RemoteSkill { description: String, } -#[derive(Debug, Deserialize)] -struct RemoteSkillsDownloadResponse { - hazelnuts: Vec, -} - -#[derive(Debug, Deserialize)] -struct RemoteSkillDownloadPayload { - id: String, - name: String, - #[serde(rename = "base_sediment_id")] - base_sediment_id: String, - files: HashMap, -} - -#[derive(Debug, Deserialize)] -struct RemoteSkillFileRangePayload { - start: u64, - length: u64, -} - -pub async fn list_remote_skills(config: &Config) -> Result> { +pub async fn list_remote_skills( + config: &Config, + auth: Option<&CodexAuth>, + hazelnut_scope: RemoteSkillHazelnutScope, + product_surface: RemoteSkillProductSurface, + enabled: Option, +) -> Result> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); - let url = format!("{base_url}/public-api/hazelnuts/"); + let auth = ensure_chatgpt_auth(auth)?; + + let url = format!("{base_url}/hazelnuts"); + let product_surface = as_query_product_surface(product_surface); + let mut query_params = vec![("product_surface", product_surface)]; + if let Some(scope) = as_query_hazelnut_scope(hazelnut_scope) { + query_params.push(("scope", scope)); + } + if let Some(enabled) = enabled { + let enabled = if enabled { "true" } else { "false" }; + query_params.push(("enabled", enabled)); + } let client = build_reqwest_client(); - let response = client + let mut request = client .get(&url) .timeout(REMOTE_SKILLS_API_TIMEOUT) - .query(&[("product_surface", "codex")]) + .query(&query_params); + let token = auth + .get_token() + .context("Failed to read auth token for remote skills")?; + request = request.bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + let response = request .send() .await .with_context(|| format!("Failed to send request to {url}"))?; @@ -107,20 +127,27 @@ pub async fn list_remote_skills(config: &Config) -> Result, hazelnut_id: &str, - is_preload: bool, ) -> Result { - let hazelnut = fetch_remote_skill(config, hazelnut_id).await?; + let auth = ensure_chatgpt_auth(auth)?; let client = build_reqwest_client(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); - let url = format!("{base_url}/public-api/hazelnuts/{hazelnut_id}/export"); - let response = client - .get(&url) - .timeout(REMOTE_SKILLS_API_TIMEOUT) + let url = format!("{base_url}/hazelnuts/{hazelnut_id}/export"); + let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT); + + let token = auth + .get_token() + .context("Failed to read auth token for remote skills")?; + request = request.bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + + let response = request .send() .await .with_context(|| format!("Failed to send download request to {url}"))?; @@ -136,48 +163,22 @@ pub async fn download_remote_skill( anyhow::bail!("Downloaded remote skill payload is not a zip archive"); } - let preferred_dir_name = if hazelnut.name.trim().is_empty() { - None - } else { - Some(hazelnut.name.as_str()) - }; - let dir_name = preferred_dir_name - .and_then(validate_dir_name_format) - .or_else(|| validate_dir_name_format(&hazelnut.id)) - .ok_or_else(|| anyhow::anyhow!("Remote skill has no valid directory name"))?; - let output_root = if is_preload { - config - .codex_home - .join("vendor_imports") - .join("skills") - .join("skills") - .join(".curated") - } else { - config.codex_home.join("skills").join("downloaded") - }; - let output_dir = output_root.join(dir_name); + let output_dir = config.codex_home.join("skills").join(hazelnut_id); tokio::fs::create_dir_all(&output_dir) .await .context("Failed to create downloaded skills directory")?; - let allowed_files = hazelnut.files.keys().cloned().collect::>(); let zip_bytes = body.to_vec(); let output_dir_clone = output_dir.clone(); - let prefix_candidates = vec![hazelnut.name.clone(), hazelnut.id.clone()]; + let prefix_candidates = vec![hazelnut_id.to_string()]; tokio::task::spawn_blocking(move || { - extract_zip_to_dir( - zip_bytes, - &output_dir_clone, - &allowed_files, - &prefix_candidates, - ) + extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates) }) .await .context("Zip extraction task failed")??; Ok(RemoteSkillDownloadResult { - id: hazelnut.id, - name: hazelnut.name, + id: hazelnut_id.to_string(), path: output_dir, }) } @@ -195,17 +196,6 @@ fn safe_join(base: &Path, name: &str) -> Result { Ok(base.join(path)) } -fn validate_dir_name_format(name: &str) -> Option { - let mut components = Path::new(name).components(); - match (components.next(), components.next()) { - (Some(Component::Normal(component)), None) => { - let value = component.to_string_lossy().to_string(); - if value.is_empty() { None } else { Some(value) } - } - _ => None, - } -} - fn is_zip_payload(bytes: &[u8]) -> bool { bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") @@ -215,7 +205,6 @@ fn is_zip_payload(bytes: &[u8]) -> bool { fn extract_zip_to_dir( bytes: Vec, output_dir: &Path, - allowed_files: &HashSet, prefix_candidates: &[String], ) -> Result<()> { let cursor = std::io::Cursor::new(bytes); @@ -230,9 +219,6 @@ fn extract_zip_to_dir( let Some(normalized) = normalized else { continue; }; - if !allowed_files.contains(&normalized) { - continue; - } let file_path = safe_join(output_dir, &normalized)?; if let Some(parent) = file_path.parent() { std::fs::create_dir_all(parent) @@ -264,51 +250,3 @@ fn normalize_zip_name(name: &str, prefix_candidates: &[String]) -> Option Result { - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); - let url = format!("{base_url}/public-api/hazelnuts/"); - - let client = build_reqwest_client(); - let response = client - .get(&url) - .timeout(REMOTE_SKILLS_API_TIMEOUT) - .query(&[("product_surface", "codex")]) - .send() - .await - .with_context(|| format!("Failed to send request to {url}"))?; - - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - anyhow::bail!("Request failed with status {status} from {url}: {body}"); - } - - let parsed: RemoteSkillsDownloadResponse = - serde_json::from_str(&body).context("Failed to parse skills response")?; - let hazelnut = parsed - .hazelnuts - .into_iter() - .find(|hazelnut| hazelnut.id == hazelnut_id) - .ok_or_else(|| anyhow::anyhow!("Remote skill {hazelnut_id} not found"))?; - - Ok(RemoteSkillDownload { - id: hazelnut.id, - name: hazelnut.name, - base_sediment_id: hazelnut.base_sediment_id, - files: hazelnut - .files - .into_iter() - .map(|(name, range)| { - ( - name, - RemoteSkillFileRange { - start: range.start, - length: range.length, - }, - ) - }) - .collect(), - }) -} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 3aec0e5350..6f640131f6 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -12,6 +12,7 @@ use crate::mcp_connection_manager::McpConnectionManager; use crate::models_manager::manager::ModelsManager; use crate::skills::SkillsManager; use crate::state_db::StateDbHandle; +use crate::tools::network_approval::NetworkApprovalService; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; use codex_hooks::Hooks; @@ -40,6 +41,7 @@ pub(crate) struct SessionServices { pub(crate) file_watcher: Arc, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: Option, + pub(crate) network_approval: Arc, pub(crate) state_db: Option, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 70fa78a280..c86f0cdfd2 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -170,6 +170,27 @@ impl SessionState { merged } + pub(crate) fn set_mcp_tool_selection(&mut self, tool_names: Vec) { + if tool_names.is_empty() { + self.active_mcp_tool_selection = None; + return; + } + + let mut selected = Vec::new(); + let mut seen = HashSet::new(); + for tool_name in tool_names { + if seen.insert(tool_name.clone()) { + selected.push(tool_name); + } + } + + self.active_mcp_tool_selection = if selected.is_empty() { + None + } else { + Some(selected) + }; + } + pub(crate) fn get_mcp_tool_selection(&self) -> Option> { self.active_mcp_tool_selection.clone() } @@ -293,6 +314,40 @@ mod tests { assert_eq!(state.get_mcp_tool_selection(), None); } + #[tokio::test] + async fn set_mcp_tool_selection_deduplicates_and_preserves_order() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + state.merge_mcp_tool_selection(vec!["mcp__rmcp__old".to_string()]); + + state.set_mcp_tool_selection(vec![ + "mcp__rmcp__echo".to_string(), + "mcp__rmcp__image".to_string(), + "mcp__rmcp__echo".to_string(), + "mcp__rmcp__search".to_string(), + ]); + + assert_eq!( + state.get_mcp_tool_selection(), + Some(vec![ + "mcp__rmcp__echo".to_string(), + "mcp__rmcp__image".to_string(), + "mcp__rmcp__search".to_string(), + ]) + ); + } + + #[tokio::test] + async fn set_mcp_tool_selection_empty_input_clears_selection() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]); + + state.set_mcp_tool_selection(Vec::new()); + + assert_eq!(state.get_mcp_tool_selection(), None); + } + #[tokio::test] // Verifies connector merging deduplicates repeated IDs. async fn merge_connector_selection_deduplicates_entries() { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 17961d38bd..eb04619ee9 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -120,7 +120,6 @@ impl Session { task: T, ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; - self.clear_mcp_tool_selection().await; self.clear_connector_selection().await; self.seed_initial_context_if_needed(turn_context.as_ref()) .await; @@ -197,7 +196,9 @@ impl Session { turn_context: Arc, last_agent_message: Option, ) { - turn_context.cancel_turn_metadata_enrichment_task(); + turn_context + .turn_metadata_state + .cancel_git_enrichment_task(); let mut active = self.active_turn.lock().await; let mut pending_input = Vec::::new(); @@ -262,7 +263,9 @@ impl Session { trace!(task_kind = ?task.kind, sub_id, "aborting running task"); task.cancellation_token.cancel(); - task.turn_context.cancel_turn_metadata_enrichment_task(); + task.turn_context + .turn_metadata_state + .cancel_git_enrichment_task(); let session_task = task.task; select! { diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 43950a8da0..80112c61c7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -119,6 +119,7 @@ pub(crate) async fn execute_user_shell_command( &display_command, session_shell.as_ref(), turn_context.cwd.as_path(), + &turn_context.shell_environment_policy.r#set, ); let call_id = Uuid::new_v4().to_string(); @@ -150,6 +151,7 @@ pub(crate) async fn execute_user_shell_command( Some(session.conversation_id), ), network: turn_context.network.clone(), + network_attempt_id: None, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index b13ef8c61a..c16a883852 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -230,12 +230,11 @@ impl ThreadManager { pub async fn list_models( &self, - config: &Config, refresh_strategy: crate::models_manager::manager::RefreshStrategy, ) -> Vec { self.state .models_manager - .list_models(config, refresh_strategy) + .list_models(refresh_strategy) .await } diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 65972f5a5a..2bacd188d5 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -314,7 +314,7 @@ impl ToolEmitter { (event, result) } Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) - | Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + | Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, .. }))) => { let response = self.format_exec_output_for_model(&output, ctx); let event = ToolEventStage::Failure(ToolEventFailure::Output(*output)); let result = Err(FunctionCallError::RespondToModel(response)); diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index aced569ce4..49c679e520 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -143,10 +143,12 @@ impl ToolHandler for ApplyPatchHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name: tool_name.to_string(), + network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) - .await; + .await + .map(|result| result.output); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), @@ -232,10 +234,12 @@ pub(crate) async fn intercept_apply_patch( turn, call_id: call_id.to_string(), tool_name: tool_name.to_string(), + network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy) - .await; + .await + .map(|result| result.output); let event_ctx = ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied()); let content = emitter.finish(event_ctx, out).await?; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d07d2e68bf..b0e1b3c0aa 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,11 +1,11 @@ pub mod apply_patch; -pub(crate) mod collab; mod dynamic; mod grep_files; mod js_repl; mod list_dir; mod mcp; mod mcp_resource; +pub(crate) mod multi_agents; mod plan; mod read_file; mod request_user_input; @@ -20,7 +20,6 @@ use serde::Deserialize; use crate::function_tool::FunctionCallError; pub use apply_patch::ApplyPatchHandler; -pub use collab::CollabHandler; pub use dynamic::DynamicToolHandler; pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; @@ -28,11 +27,13 @@ pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; +pub use multi_agents::MultiAgentHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; pub use request_user_input::RequestUserInputHandler; pub(crate) use request_user_input::request_user_input_tool_description; pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT; +pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME; pub use search_tool_bm25::SearchToolBm25Handler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs similarity index 93% rename from codex-rs/core/src/tools/handlers/collab.rs rename to codex-rs/core/src/tools/handlers/multi_agents.rs index 8e5f23450c..f005c3f3cb 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -34,7 +34,7 @@ use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; -pub struct CollabHandler; +pub struct MultiAgentHandler; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; @@ -47,7 +47,7 @@ struct CloseAgentArgs { } #[async_trait] -impl ToolHandler for CollabHandler { +impl ToolHandler for MultiAgentHandler { fn kind(&self) -> ToolKind { ToolKind::Function } @@ -90,7 +90,7 @@ impl ToolHandler for CollabHandler { mod spawn { use super::*; - use crate::agent::AgentRole; + use crate::agent::role::apply_role_to_config; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; @@ -100,7 +100,7 @@ mod spawn { struct SpawnAgentArgs { message: Option, items: Option>, - agent_type: Option, + agent_type: Option, } #[derive(Debug, Serialize)] @@ -115,7 +115,11 @@ mod spawn { arguments: String, ) -> Result { let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let agent_role = args.agent_type.unwrap_or(AgentRole::Default); + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); let input_items = parse_collab_input(args.message, args.items)?; let prompt = input_preview(&input_items); let session_source = turn.session_source.clone(); @@ -141,9 +145,10 @@ mod spawn { turn.as_ref(), child_depth, )?; - agent_role - .apply_to_config(&mut config) + apply_role_to_config(&mut config, role_name) + .await .map_err(FunctionCallError::RespondToModel)?; + apply_spawn_agent_overrides(&mut config, child_depth); let result = session .services @@ -821,7 +826,6 @@ fn build_agent_shared_config( config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); config.cwd = turn.cwd.clone(); - config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); config .permissions .sandbox_policy @@ -829,13 +833,16 @@ fn build_agent_shared_config( .map_err(|err| { FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) })?; + apply_spawn_agent_overrides(&mut config, child_depth); - // If the new agent will be at max depth: + Ok(config) +} + +fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { + config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); if exceeds_thread_spawn_depth_limit(child_depth + 1) { config.features.disable(Feature::Collab); } - - Ok(config) } #[cfg(test)] @@ -910,7 +917,7 @@ mod tests { input: "hello".to_string(), }, ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("payload should be rejected"); }; assert_eq!( @@ -930,7 +937,7 @@ mod tests { "unknown_tool", function_payload(json!({})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("tool should be rejected"); }; assert_eq!( @@ -948,7 +955,7 @@ mod tests { "spawn_agent", function_payload(json!({"message": " "})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("empty message should be rejected"); }; assert_eq!( @@ -971,7 +978,7 @@ mod tests { "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] })), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("message+items should be rejected"); }; assert_eq!( @@ -982,6 +989,57 @@ mod tests { ); } + #[tokio::test] + async fn spawn_agent_uses_explorer_role_and_sets_never_approval_policy() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let mut config = (*turn.config).clone(); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy should be set"); + turn.config = Arc::new(config); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "agent_type": "explorer" + })), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("spawn_agent should succeed"); + let ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + .. + } = output + else { + panic!("expected function output"); + }; + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); + let snapshot = manager + .get_thread(agent_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.model, "gpt-5.1-codex-mini"); + assert_eq!(snapshot.approval_policy, AskForApproval::Never); + } + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; @@ -991,7 +1049,7 @@ mod tests { "spawn_agent", function_payload(json!({"message": "hello"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("spawn should fail without a manager"); }; assert_eq!( @@ -1017,7 +1075,7 @@ mod tests { "spawn_agent", function_payload(json!({"message": "hello"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("spawn should fail when depth limit exceeded"); }; assert_eq!( @@ -1037,7 +1095,7 @@ mod tests { "send_input", function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("empty message should be rejected"); }; assert_eq!( @@ -1061,7 +1119,7 @@ mod tests { "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] })), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("message+items should be rejected"); }; assert_eq!( @@ -1081,7 +1139,7 @@ mod tests { "send_input", function_payload(json!({"id": "not-a-uuid", "message": "hi"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { @@ -1102,7 +1160,7 @@ mod tests { "send_input", function_payload(json!({"id": agent_id.to_string(), "message": "hi"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("missing agent should be reported"); }; assert_eq!( @@ -1129,7 +1187,7 @@ mod tests { "interrupt": true })), ); - CollabHandler + MultiAgentHandler .handle(invocation) .await .expect("send_input should succeed"); @@ -1170,7 +1228,7 @@ mod tests { ] })), ); - CollabHandler + MultiAgentHandler .handle(invocation) .await .expect("send_input should succeed"); @@ -1210,7 +1268,7 @@ mod tests { "resume_agent", function_payload(json!({"id": "not-a-uuid"})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { @@ -1231,7 +1289,7 @@ mod tests { "resume_agent", function_payload(json!({"id": agent_id.to_string()})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("missing agent should be reported"); }; assert_eq!( @@ -1256,7 +1314,7 @@ mod tests { function_payload(json!({"id": agent_id.to_string()})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("resume_agent should succeed"); @@ -1325,7 +1383,7 @@ mod tests { "resume_agent", function_payload(json!({"id": agent_id.to_string()})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(resume_invocation) .await .expect("resume_agent should succeed"); @@ -1348,7 +1406,7 @@ mod tests { "send_input", function_payload(json!({"id": agent_id.to_string(), "message": "hello"})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(send_invocation) .await .expect("send_input should succeed after resume"); @@ -1393,7 +1451,7 @@ mod tests { "resume_agent", function_payload(json!({"id": ThreadId::new().to_string()})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("resume should fail when depth limit exceeded"); }; assert_eq!( @@ -1422,7 +1480,7 @@ mod tests { "timeout_ms": 0 })), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("non-positive timeout should be rejected"); }; assert_eq!( @@ -1440,7 +1498,7 @@ mod tests { "wait", function_payload(json!({"ids": ["invalid"]})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { @@ -1458,7 +1516,7 @@ mod tests { "wait", function_payload(json!({"ids": []})), ); - let Err(err) = CollabHandler.handle(invocation).await else { + let Err(err) = MultiAgentHandler.handle(invocation).await else { panic!("empty ids should be rejected"); }; assert_eq!( @@ -1483,7 +1541,7 @@ mod tests { "timeout_ms": 1000 })), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("wait should succeed"); @@ -1527,7 +1585,7 @@ mod tests { "timeout_ms": MIN_WAIT_TIMEOUT_MS })), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("wait should succeed"); @@ -1575,7 +1633,11 @@ mod tests { })), ); - let early = timeout(Duration::from_millis(50), CollabHandler.handle(invocation)).await; + let early = timeout( + Duration::from_millis(50), + MultiAgentHandler.handle(invocation), + ) + .await; assert!( early.is_err(), "wait should not return before the minimum timeout clamp" @@ -1620,7 +1682,7 @@ mod tests { "timeout_ms": 1000 })), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("wait should succeed"); @@ -1660,7 +1722,7 @@ mod tests { "close_agent", function_payload(json!({"id": agent_id.to_string()})), ); - let output = CollabHandler + let output = MultiAgentHandler .handle(invocation) .await .expect("close_agent should succeed"); diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index 39f422f5fe..b8240554e6 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -11,7 +11,6 @@ use std::collections::HashSet; use crate::app_tool_policy; use crate::connectors; -use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; @@ -24,6 +23,7 @@ use crate::tools::registry::ToolKind; pub struct SearchToolBm25Handler; +pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; pub(crate) const DEFAULT_LIMIT: usize = 8; fn default_limit() -> usize { @@ -43,7 +43,6 @@ struct ToolEntry { server_name: String, title: Option, description: Option, - connector_id: Option, connector_name: Option, input_keys: Vec, search_text: String, @@ -67,7 +66,6 @@ impl ToolEntry { .tool .description .map(|description| description.to_string()), - connector_id: info.connector_id, connector_name: info.connector_name, input_keys, search_text, @@ -92,9 +90,9 @@ impl ToolHandler for SearchToolBm25Handler { let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { - return Err(FunctionCallError::Fatal( - "search_tool_bm25 handler received unsupported payload".to_string(), - )); + return Err(FunctionCallError::Fatal(format!( + "{SEARCH_TOOL_BM25_TOOL_NAME} handler received unsupported payload" + ))); } }; @@ -121,16 +119,13 @@ impl ToolHandler for SearchToolBm25Handler { .await .list_all_tools() .await; - let mcp_tools = if turn.config.features.enabled(Feature::Apps) { - let apps_config = app_tool_policy::read_apps_config(&turn.config); - let connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, - ); - filter_codex_apps_mcp_tools(mcp_tools, &connectors, apps_config.as_ref()) - } else { - mcp_tools - }; + let apps_config = app_tool_policy::read_apps_config(&turn.config); + + let connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors, apps_config.as_ref()); let mut entries: Vec = mcp_tools .into_iter() @@ -174,7 +169,6 @@ impl ToolHandler for SearchToolBm25Handler { "server": entry.server_name.clone(), "title": entry.title.clone(), "description": entry.description.clone(), - "connector_id": entry.connector_id.clone(), "connector_name": entry.connector_name.clone(), "input_keys": entry.input_keys.clone(), "score": result.score, @@ -252,12 +246,6 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri parts.push(connector_name.to_string()); } - if let Some(connector_id) = info.connector_id.as_deref() - && !connector_id.trim().is_empty() - { - parts.push(connector_id.to_string()); - } - if !input_keys.is_empty() { parts.extend(input_keys.iter().cloned()); } @@ -283,6 +271,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: None, is_accessible: true, is_enabled: enabled, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b9e2a97d6d..31bf3f1294 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -54,6 +54,7 @@ impl ShellHandler { expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), + network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -83,6 +84,7 @@ impl ShellCommandHandler { expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), + network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -241,18 +243,17 @@ impl ShellHandler { freeform, } = args; - let features = session.features(); - let request_rule_enabled = features.enabled(crate::features::Feature::RequestRule); - let prefix_rule = if request_rule_enabled { - prefix_rule - } else { - None - }; - let mut exec_params = exec_params; let dependency_env = session.dependency_env().await; if !dependency_env.is_empty() { - exec_params.env.extend(dependency_env); + exec_params.env.extend(dependency_env.clone()); + } + + let mut explicit_env_overrides = turn.shell_environment_policy.r#set.clone(); + for key in dependency_env.keys() { + if let Some(value) = exec_params.env.get(key) { + explicit_env_overrides.insert(key.clone(), value.clone()); + } } // Approval policy guard for explicit escalation in non-OnRequest modes. @@ -313,6 +314,7 @@ impl ShellHandler { cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), + explicit_env_overrides, network: exec_params.network.clone(), sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), @@ -325,10 +327,12 @@ impl ShellHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name, + network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) - .await; + .await + .map(|result| result.output); let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); let content = emitter.finish(event_ctx, out).await?; Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c06889b37b..e689e5bba9 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -142,14 +142,6 @@ impl ToolHandler for UnifiedExecHandler { .. } = args; - let features = session.features(); - let request_rule_enabled = features.enabled(crate::features::Feature::RequestRule); - let prefix_rule = if request_rule_enabled { - prefix_rule - } else { - None - }; - if sandbox_permissions.requires_escalated_permissions() && !matches!( context.turn.approval_policy, diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 1aad8cfba5..ef0cdad004 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -114,6 +114,7 @@ struct ExecContext { struct ExecToolCalls { in_flight: usize, notify: Arc, + cancel: CancellationToken, } enum KernelStreamEnd { @@ -300,37 +301,22 @@ impl JsReplManager { async fn clear_exec_tool_calls(&self, exec_id: &str) { if let Some(state) = self.exec_tool_calls.lock().await.remove(exec_id) { + state.cancel.cancel(); state.notify.notify_waiters(); } } async fn wait_for_exec_tool_calls(&self, exec_id: &str) { loop { - let notify = { + let notified = { let calls = self.exec_tool_calls.lock().await; calls .get(exec_id) .filter(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify)) + .map(|state| Arc::clone(&state.notify).notified_owned()) }; - match notify { - Some(notify) => notify.notified().await, - None => return, - } - } - } - - async fn wait_for_all_exec_tool_calls(&self) { - loop { - let notify = { - let calls = self.exec_tool_calls.lock().await; - calls - .values() - .find(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify)) - }; - match notify { - Some(notify) => notify.notified().await, + match notified { + Some(notified) => notified.await, None => return, } } @@ -339,13 +325,11 @@ impl JsReplManager { async fn begin_exec_tool_call( exec_tool_calls: &Arc>>, exec_id: &str, - ) -> bool { + ) -> Option { let mut calls = exec_tool_calls.lock().await; - let Some(state) = calls.get_mut(exec_id) else { - return false; - }; + let state = calls.get_mut(exec_id)?; state.in_flight += 1; - true + Some(state.cancel.clone()) } async fn finish_exec_tool_call( @@ -377,15 +361,15 @@ impl JsReplManager { exec_id: &str, ) { loop { - let notify = { + let notified = { let calls = exec_tool_calls.lock().await; calls .get(exec_id) .filter(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify)) + .map(|state| Arc::clone(&state.notify).notified_owned()) }; - match notify { - Some(notify) => notify.notified().await, + match notified { + Some(notified) => notified.await, None => return, } } @@ -396,14 +380,30 @@ impl JsReplManager { exec_id: &str, ) { if let Some(state) = exec_tool_calls.lock().await.remove(exec_id) { + state.cancel.cancel(); + state.notify.notify_waiters(); + } + } + + async fn clear_all_exec_tool_calls_map( + exec_tool_calls: &Arc>>, + ) { + let states = { + let mut calls = exec_tool_calls.lock().await; + calls.drain().map(|(_, state)| state).collect::>() + }; + for state in states { + state.cancel.cancel(); state.notify.notify_waiters(); } } pub async fn reset(&self) -> Result<(), FunctionCallError> { + let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| { + FunctionCallError::RespondToModel("js_repl execution unavailable".to_string()) + })?; self.reset_kernel().await; - self.wait_for_all_exec_tool_calls().await; - self.exec_tool_calls.lock().await.clear(); + Self::clear_all_exec_tool_calls_map(&self.exec_tool_calls).await; Ok(()) } @@ -530,7 +530,9 @@ impl JsReplManager { return Err(FunctionCallError::RespondToModel(message)); } Err(_) => { - self.reset().await?; + self.reset_kernel().await; + self.wait_for_exec_tool_calls(&req_id).await; + self.exec_tool_calls.lock().await.clear(); return Err(FunctionCallError::RespondToModel( "js_repl execution timed out; kernel reset, rerun your request".to_string(), )); @@ -854,7 +856,9 @@ impl JsReplManager { JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &id).await; } KernelToHost::RunTool(req) => { - if !JsReplManager::begin_exec_tool_call(&exec_tool_calls, &req.exec_id).await { + let Some(reset_cancel) = + JsReplManager::begin_exec_tool_call(&exec_tool_calls, &req.exec_id).await + else { let exec_id = req.exec_id.clone(); let tool_call_id = req.id.clone(); let payload = HostToKernel::RunToolResult(RunToolResult { @@ -877,10 +881,10 @@ impl JsReplManager { ); } continue; - } + }; let stdin_clone = Arc::clone(&stdin); let exec_contexts = Arc::clone(&exec_contexts); - let exec_tool_calls = Arc::clone(&exec_tool_calls); + let exec_tool_calls_for_task = Arc::clone(&exec_tool_calls); let recent_stderr = Arc::clone(&recent_stderr); tokio::spawn(async move { let exec_id = req.exec_id.clone(); @@ -888,15 +892,26 @@ impl JsReplManager { let tool_name = req.tool_name.clone(); let context = { exec_contexts.lock().await.get(&exec_id).cloned() }; let result = match context { - Some(ctx) => JsReplManager::run_tool_request(ctx, req).await, + Some(ctx) => { + tokio::select! { + _ = reset_cancel.cancelled() => RunToolResult { + id: tool_call_id.clone(), + ok: false, + response: None, + error: Some("js_repl execution reset".to_string()), + }, + result = JsReplManager::run_tool_request(ctx, req) => result, + } + } None => RunToolResult { - id: req.id.clone(), + id: tool_call_id.clone(), ok: false, response: None, error: Some("js_repl exec context not found".to_string()), }, }; - JsReplManager::finish_exec_tool_call(&exec_tool_calls, &exec_id).await; + JsReplManager::finish_exec_tool_call(&exec_tool_calls_for_task, &exec_id) + .await; let payload = HostToKernel::RunToolResult(result); if let Err(err) = JsReplManager::write_message(&stdin_clone, &payload).await { @@ -1407,6 +1422,148 @@ mod tests { assert!(!is_js_repl_internal_tool("list_mcp_resources")); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn wait_for_exec_tool_calls_map_drains_inflight_calls_without_hanging() { + let exec_tool_calls = Arc::new(Mutex::new(HashMap::new())); + + for _ in 0..128 { + let exec_id = Uuid::new_v4().to_string(); + exec_tool_calls + .lock() + .await + .insert(exec_id.clone(), ExecToolCalls::default()); + assert!( + JsReplManager::begin_exec_tool_call(&exec_tool_calls, &exec_id) + .await + .is_some() + ); + + let wait_map = Arc::clone(&exec_tool_calls); + let wait_exec_id = exec_id.clone(); + let waiter = tokio::spawn(async move { + JsReplManager::wait_for_exec_tool_calls_map(&wait_map, &wait_exec_id).await; + }); + + let finish_map = Arc::clone(&exec_tool_calls); + let finish_exec_id = exec_id.clone(); + let finisher = tokio::spawn(async move { + tokio::task::yield_now().await; + JsReplManager::finish_exec_tool_call(&finish_map, &finish_exec_id).await; + }); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("wait_for_exec_tool_calls_map should not hang") + .expect("wait task should not panic"); + finisher.await.expect("finish task should not panic"); + + JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reset_waits_for_exec_lock_before_clearing_exec_tool_calls() { + let manager = JsReplManager::new(None, PathBuf::from(".")) + .await + .expect("manager should initialize"); + let permit = manager + .exec_lock + .clone() + .acquire_owned() + .await + .expect("lock should be acquirable"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + + let reset_manager = Arc::clone(&manager); + let mut reset_task = tokio::spawn(async move { reset_manager.reset().await }); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!( + !reset_task.is_finished(), + "reset should wait until execute lock is released" + ); + assert!( + manager.exec_tool_calls.lock().await.contains_key(&exec_id), + "reset must not clear tool-call contexts while execute lock is held" + ); + + drop(permit); + + tokio::time::timeout(Duration::from_secs(1), &mut reset_task) + .await + .expect("reset should complete after execute lock release") + .expect("reset task should not panic") + .expect("reset should succeed"); + assert!( + !manager.exec_tool_calls.lock().await.contains_key(&exec_id), + "reset should clear tool-call contexts after lock acquisition" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reset_clears_inflight_exec_tool_calls_without_waiting() { + let manager = JsReplManager::new(None, std::env::temp_dir()) + .await + .expect("manager should initialize"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + assert!( + JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) + .await + .is_some() + ); + + let wait_manager = Arc::clone(&manager); + let wait_exec_id = exec_id.clone(); + let waiter = tokio::spawn(async move { + wait_manager.wait_for_exec_tool_calls(&wait_exec_id).await; + }); + tokio::task::yield_now().await; + + tokio::time::timeout(Duration::from_secs(1), manager.reset()) + .await + .expect("reset should not hang") + .expect("reset should succeed"); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("waiter should be released") + .expect("wait task should not panic"); + + assert!(manager.exec_tool_calls.lock().await.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reset_aborts_inflight_exec_tool_tasks() { + let manager = JsReplManager::new(None, std::env::temp_dir()) + .await + .expect("manager should initialize"); + let exec_id = Uuid::new_v4().to_string(); + manager.register_exec_tool_calls(&exec_id).await; + let reset_cancel = JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) + .await + .expect("exec should be registered"); + + let task = tokio::spawn(async move { + tokio::select! { + _ = reset_cancel.cancelled() => "cancelled", + _ = tokio::time::sleep(Duration::from_secs(60)) => "timed_out", + } + }); + + tokio::time::timeout(Duration::from_secs(1), manager.reset()) + .await + .expect("reset should not hang") + .expect("reset should succeed"); + + let outcome = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("cancelled task should resolve promptly") + .expect("task should not panic"); + assert_eq!(outcome, "cancelled"); + } + async fn can_run_js_repl_runtime_tests() -> bool { if std::env::var_os("CODEX_SANDBOX").is_some() { return false; @@ -1746,7 +1903,7 @@ console.log("cell-complete"); Ok(()) } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn js_repl_can_attach_image_via_view_image_tool() -> anyhow::Result<()> { if !can_run_js_repl_runtime_tests().await { return Ok(()); @@ -1795,7 +1952,6 @@ console.log(out.output?.body?.text ?? ""); ) .await?; assert!(result.output.contains("function_call_output")); - assert!(result.output.contains("attached local image path")); let pending_input = session.get_pending_input().await; let image_url = pending_input diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 3307d65313..27fe515126 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -2,6 +2,7 @@ pub mod context; pub mod events; pub(crate) mod handlers; pub mod js_repl; +pub(crate) mod network_approval; pub mod orchestrator; pub mod parallel; pub mod registry; diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs new file mode 100644 index 0000000000..51e6e3c98e --- /dev/null +++ b/codex-rs/core/src/tools/network_approval.rs @@ -0,0 +1,675 @@ +use crate::codex::Session; +use crate::network_policy_decision::denied_network_policy_message; +use crate::tools::sandboxing::ToolError; +use codex_network_proxy::BlockedRequest; +use codex_network_proxy::BlockedRequestObserver; +use codex_network_proxy::NetworkDecision; +use codex_network_proxy::NetworkPolicyDecider; +use codex_network_proxy::NetworkPolicyRequest; +use codex_network_proxy::NetworkProtocol; +use codex_network_proxy::NetworkProxy; +use codex_protocol::approvals::NetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::protocol::ReviewDecision; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::RwLock; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum NetworkApprovalMode { + Immediate, + Deferred, +} + +#[derive(Clone, Debug)] +pub(crate) struct NetworkApprovalSpec { + pub command: Vec, + pub cwd: PathBuf, + pub network: Option, + pub mode: NetworkApprovalMode, +} + +#[derive(Clone, Debug)] +pub(crate) struct DeferredNetworkApproval { + attempt_id: String, +} + +impl DeferredNetworkApproval { + pub(crate) fn attempt_id(&self) -> &str { + &self.attempt_id + } +} + +#[derive(Debug)] +pub(crate) struct ActiveNetworkApproval { + attempt_id: Option, + mode: NetworkApprovalMode, +} + +impl ActiveNetworkApproval { + pub(crate) fn attempt_id(&self) -> Option<&str> { + self.attempt_id.as_deref() + } + + pub(crate) fn mode(&self) -> NetworkApprovalMode { + self.mode + } + + pub(crate) fn into_deferred(self) -> Option { + match (self.mode, self.attempt_id) { + (NetworkApprovalMode::Deferred, Some(attempt_id)) => { + Some(DeferredNetworkApproval { attempt_id }) + } + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum NetworkApprovalOutcome { + DeniedByUser, + DeniedByPolicy(String), +} + +struct NetworkApprovalAttempt { + turn_id: String, + call_id: String, + command: Vec, + cwd: PathBuf, + approved_hosts: Mutex>, + outcome: Mutex>, +} + +pub(crate) struct NetworkApprovalService { + attempts: Mutex>>, + session_approved_hosts: Mutex>, +} + +impl Default for NetworkApprovalService { + fn default() -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + session_approved_hosts: Mutex::new(HashSet::new()), + } + } +} + +impl NetworkApprovalService { + pub(crate) async fn register_attempt( + &self, + attempt_id: String, + turn_id: String, + call_id: String, + command: Vec, + cwd: PathBuf, + ) { + let mut attempts = self.attempts.lock().await; + attempts.insert( + attempt_id, + Arc::new(NetworkApprovalAttempt { + turn_id, + call_id, + command, + cwd, + approved_hosts: Mutex::new(HashSet::new()), + outcome: Mutex::new(None), + }), + ); + } + + pub(crate) async fn unregister_attempt(&self, attempt_id: &str) { + let mut attempts = self.attempts.lock().await; + attempts.remove(attempt_id); + } + + pub(crate) async fn take_outcome(&self, attempt_id: &str) -> Option { + let attempt = { + let attempts = self.attempts.lock().await; + attempts.get(attempt_id).cloned() + }?; + let mut outcome = attempt.outcome.lock().await; + outcome.take() + } + + pub(crate) async fn take_user_denial_outcome(&self, attempt_id: &str) -> bool { + let attempt = { + let attempts = self.attempts.lock().await; + attempts.get(attempt_id).cloned() + }; + let Some(attempt) = attempt else { + return false; + }; + let mut outcome = attempt.outcome.lock().await; + if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { + outcome.take(); + return true; + } + false + } + + async fn resolve_attempt_for_request( + &self, + request: &NetworkPolicyRequest, + ) -> Option> { + let attempts = self.attempts.lock().await; + + if let Some(attempt_id) = request.attempt_id.as_deref() { + if let Some(attempt) = attempts.get(attempt_id).cloned() { + return Some(attempt); + } + return None; + } + + if attempts.len() == 1 { + return attempts.values().next().cloned(); + } + + None + } + + async fn resolve_attempt_for_blocked_request( + &self, + blocked: &BlockedRequest, + ) -> Option> { + let attempts = self.attempts.lock().await; + + if let Some(attempt_id) = blocked.attempt_id.as_deref() { + if let Some(attempt) = attempts.get(attempt_id).cloned() { + return Some(attempt); + } + return None; + } + + if attempts.len() == 1 { + return attempts.values().next().cloned(); + } + + None + } + + pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) { + let Some(message) = denied_network_policy_message(&blocked) else { + return; + }; + + let Some(attempt) = self.resolve_attempt_for_blocked_request(&blocked).await else { + return; + }; + + let mut outcome = attempt.outcome.lock().await; + if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { + return; + } + *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(message)); + } + + pub(crate) async fn handle_inline_policy_request( + &self, + session: &Session, + request: NetworkPolicyRequest, + ) -> NetworkDecision { + const REASON_NOT_ALLOWED: &str = "not_allowed"; + + { + let approved_hosts = self.session_approved_hosts.lock().await; + if approved_hosts.contains(request.host.as_str()) { + return NetworkDecision::Allow; + } + } + + let Some(attempt) = self.resolve_attempt_for_request(&request).await else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; + + { + let approved_hosts = attempt.approved_hosts.lock().await; + if approved_hosts.contains(request.host.as_str()) { + return NetworkDecision::Allow; + } + } + + let protocol = match request.protocol { + NetworkProtocol::Http => NetworkApprovalProtocol::Http, + NetworkProtocol::HttpsConnect => NetworkApprovalProtocol::Https, + NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, + NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, + }; + + let Some(turn_context) = session.turn_context_for_sub_id(&attempt.turn_id).await else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; + + let approval_decision = session + .request_command_approval( + turn_context.as_ref(), + attempt.call_id.clone(), + attempt.command.clone(), + attempt.cwd.clone(), + Some(format!( + "Network access to \"{}\" is blocked by policy.", + request.host + )), + Some(NetworkApprovalContext { + host: request.host.clone(), + protocol, + }), + None, + ) + .await; + + match approval_decision { + ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + let mut approved_hosts = attempt.approved_hosts.lock().await; + approved_hosts.insert(request.host); + NetworkDecision::Allow + } + ReviewDecision::ApprovedForSession => { + let mut approved_hosts = self.session_approved_hosts.lock().await; + approved_hosts.insert(request.host); + NetworkDecision::Allow + } + ReviewDecision::Denied | ReviewDecision::Abort => { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByUser); + NetworkDecision::deny(REASON_NOT_ALLOWED) + } + } + } +} + +pub(crate) fn build_blocked_request_observer( + network_approval: Arc, +) -> Arc { + Arc::new(move |blocked: BlockedRequest| { + let network_approval = Arc::clone(&network_approval); + async move { + network_approval.record_blocked_request(blocked).await; + } + }) +} + +pub(crate) fn build_network_policy_decider( + network_approval: Arc, + network_policy_decider_session: Arc>>, +) -> Arc { + Arc::new(move |request: NetworkPolicyRequest| { + let network_approval = Arc::clone(&network_approval); + let network_policy_decider_session = Arc::clone(&network_policy_decider_session); + async move { + let Some(session) = network_policy_decider_session.read().await.upgrade() else { + return NetworkDecision::ask("not_allowed"); + }; + network_approval + .handle_inline_policy_request(session.as_ref(), request) + .await + } + }) +} + +pub(crate) async fn begin_network_approval( + session: &Session, + turn_id: &str, + call_id: &str, + has_managed_network_requirements: bool, + spec: Option, +) -> Option { + let spec = spec?; + if !has_managed_network_requirements || spec.network.is_none() { + return None; + } + + let attempt_id = Uuid::new_v4().to_string(); + session + .services + .network_approval + .register_attempt( + attempt_id.clone(), + turn_id.to_string(), + call_id.to_string(), + spec.command, + spec.cwd, + ) + .await; + + Some(ActiveNetworkApproval { + attempt_id: Some(attempt_id), + mode: spec.mode, + }) +} + +pub(crate) async fn finish_immediate_network_approval( + session: &Session, + active: ActiveNetworkApproval, +) -> Result<(), ToolError> { + let Some(attempt_id) = active.attempt_id.as_deref() else { + return Ok(()); + }; + + let approval_outcome = session + .services + .network_approval + .take_outcome(attempt_id) + .await; + + session + .services + .network_approval + .unregister_attempt(attempt_id) + .await; + + match approval_outcome { + Some(NetworkApprovalOutcome::DeniedByUser) => { + Err(ToolError::Rejected("rejected by user".to_string())) + } + Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Err(ToolError::Rejected(message)), + None => Ok(()), + } +} + +pub(crate) async fn deferred_rejection_message( + session: &Session, + deferred: &DeferredNetworkApproval, +) -> Option { + match session + .services + .network_approval + .take_outcome(deferred.attempt_id()) + .await + { + Some(NetworkApprovalOutcome::DeniedByUser) => Some("rejected by user".to_string()), + Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Some(message), + None => None, + } +} + +pub(crate) async fn finish_deferred_network_approval( + session: &Session, + deferred: Option, +) { + let Some(deferred) = deferred else { + return; + }; + session + .services + .network_approval + .unregister_attempt(deferred.attempt_id()) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_network_proxy::BlockedRequestArgs; + use codex_network_proxy::NetworkPolicyRequestArgs; + use pretty_assertions::assert_eq; + + fn http_request(host: &str, attempt_id: Option<&str>) -> NetworkPolicyRequest { + NetworkPolicyRequest::new(NetworkPolicyRequestArgs { + protocol: NetworkProtocol::Http, + host: host.to_string(), + port: 80, + client_addr: None, + method: Some("GET".to_string()), + command: None, + exec_policy_hint: None, + attempt_id: attempt_id.map(ToString::to_string), + }) + } + + #[tokio::test] + async fn resolve_attempt_for_request_falls_back_to_single_active_attempt() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("example.com", None)) + .await + .expect("single active attempt should be used as fallback"); + assert_eq!(resolved.call_id, "call-1"); + } + + #[tokio::test] + async fn resolve_attempt_for_request_returns_exact_attempt_match() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + service + .register_attempt( + "attempt-2".to_string(), + "turn-2".to_string(), + "call-2".to_string(), + vec!["curl".to_string(), "openai.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("openai.com", Some("attempt-2"))) + .await + .expect("attempt-2 should resolve"); + assert_eq!(resolved.call_id, "call-2"); + } + + #[tokio::test] + async fn resolve_attempt_for_request_returns_none_for_unknown_attempt_id() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("example.com", Some("attempt-unknown"))) + .await; + assert!(resolved.is_none()); + } + + #[tokio::test] + async fn resolve_attempt_for_request_returns_none_when_ambiguous() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + service + .register_attempt( + "attempt-2".to_string(), + "turn-2".to_string(), + "call-2".to_string(), + vec!["curl".to_string(), "robinhood.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let resolved = service + .resolve_attempt_for_request(&http_request("example.com", None)) + .await; + assert!(resolved.is_none()); + } + + #[tokio::test] + async fn take_outcome_clears_stored_value() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let attempt = { + let attempts = service.attempts.lock().await; + attempts + .get("attempt-1") + .cloned() + .expect("attempt should exist") + }; + { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByUser); + } + + assert_eq!( + service.take_outcome("attempt-1").await, + Some(NetworkApprovalOutcome::DeniedByUser) + ); + assert_eq!(service.take_outcome("attempt-1").await, None); + } + + #[tokio::test] + async fn take_user_denial_outcome_preserves_policy_denial() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let attempt = { + let attempts = service.attempts.lock().await; + attempts + .get("attempt-1") + .cloned() + .expect("attempt should exist") + }; + { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy( + "policy denied".to_string(), + )); + } + + assert!(!service.take_user_denial_outcome("attempt-1").await); + assert_eq!( + service.take_outcome("attempt-1").await, + Some(NetworkApprovalOutcome::DeniedByPolicy( + "policy denied".to_string(), + )) + ); + } + + #[tokio::test] + async fn record_blocked_request_stores_policy_denial_outcome() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + service + .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { + host: "example.com".to_string(), + reason: "denied".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("deny".to_string()), + source: Some("baseline_policy".to_string()), + port: Some(80), + })) + .await; + + let outcome = service + .take_outcome("attempt-1") + .await + .expect("outcome should be recorded"); + match outcome { + NetworkApprovalOutcome::DeniedByPolicy(message) => { + assert_eq!( + message, + "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() + ); + } + NetworkApprovalOutcome::DeniedByUser => panic!("expected policy denial"), + } + } + + #[tokio::test] + async fn record_blocked_request_does_not_override_user_denial() { + let service = NetworkApprovalService::default(); + service + .register_attempt( + "attempt-1".to_string(), + "turn-1".to_string(), + "call-1".to_string(), + vec!["curl".to_string(), "example.com".to_string()], + std::env::temp_dir(), + ) + .await; + + let attempt = { + let attempts = service.attempts.lock().await; + attempts + .get("attempt-1") + .cloned() + .expect("attempt should exist") + }; + { + let mut outcome = attempt.outcome.lock().await; + *outcome = Some(NetworkApprovalOutcome::DeniedByUser); + } + + service + .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { + host: "example.com".to_string(), + reason: "denied".to_string(), + client: None, + method: Some("GET".to_string()), + mode: None, + protocol: "http".to_string(), + attempt_id: Some("attempt-1".to_string()), + decision: Some("deny".to_string()), + source: Some("baseline_policy".to_string()), + port: Some(80), + })) + .await; + + assert_eq!( + service.take_outcome("attempt-1").await, + Some(NetworkApprovalOutcome::DeniedByUser) + ); + } +} diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 3f410e1b26..cdbaac0254 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -10,7 +10,13 @@ use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecToolCallOutput; use crate::features::Feature; +use crate::network_policy_decision::network_approval_context_from_payload; use crate::sandboxing::SandboxManager; +use crate::tools::network_approval::DeferredNetworkApproval; +use crate::tools::network_approval::NetworkApprovalMode; +use crate::tools::network_approval::begin_network_approval; +use crate::tools::network_approval::finish_deferred_network_approval; +use crate::tools::network_approval::finish_immediate_network_approval; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; @@ -27,6 +33,11 @@ pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, } +pub(crate) struct OrchestratorRunResult { + pub output: Out, + pub deferred_network_approval: Option, +} + impl ToolOrchestrator { pub fn new() -> Self { Self { @@ -34,6 +45,60 @@ impl ToolOrchestrator { } } + async fn run_attempt( + tool: &mut T, + req: &Rq, + tool_ctx: &ToolCtx<'_>, + attempt: &SandboxAttempt<'_>, + has_managed_network_requirements: bool, + ) -> (Result, Option) + where + T: ToolRuntime, + { + let network_approval = begin_network_approval( + tool_ctx.session, + &tool_ctx.turn.sub_id, + &tool_ctx.call_id, + has_managed_network_requirements, + tool.network_approval_spec(req, tool_ctx), + ) + .await; + + let attempt_tool_ctx = ToolCtx { + session: tool_ctx.session, + turn: tool_ctx.turn, + call_id: tool_ctx.call_id.clone(), + tool_name: tool_ctx.tool_name.clone(), + network_attempt_id: network_approval.as_ref().and_then(|network_approval| { + network_approval.attempt_id().map(ToString::to_string) + }), + }; + let run_result = tool.run(req, attempt, &attempt_tool_ctx).await; + + let Some(network_approval) = network_approval else { + return (run_result, None); + }; + + match network_approval.mode() { + NetworkApprovalMode::Immediate => { + let finalize_result = + finish_immediate_network_approval(tool_ctx.session, network_approval).await; + if let Err(err) = finalize_result { + return (Err(err), None); + } + (run_result, None) + } + NetworkApprovalMode::Deferred => { + let deferred = network_approval.into_deferred(); + if run_result.is_err() { + finish_deferred_network_approval(tool_ctx.session, deferred).await; + return (run_result, None); + } + (run_result, deferred) + } + } + } + pub async fn run( &mut self, tool: &mut T, @@ -41,7 +106,7 @@ impl ToolOrchestrator { tool_ctx: &ToolCtx<'_>, turn_ctx: &crate::codex::TurnContext, approval_policy: AskForApproval, - ) -> Result + ) -> Result, ToolError> where T: ToolRuntime, { @@ -70,6 +135,7 @@ impl ToolOrchestrator { turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: reason, + network_approval_context: None, }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -118,33 +184,86 @@ impl ToolOrchestrator { windows_sandbox_level: turn_ctx.windows_sandbox_level, }; - match tool.run(req, &initial_attempt, tool_ctx).await { + let (first_result, first_deferred_network_approval) = Self::run_attempt( + tool, + req, + tool_ctx, + &initial_attempt, + has_managed_network_requirements, + ) + .await; + match first_result { Ok(out) => { // We have a successful initial result - Ok(out) + Ok(OrchestratorRunResult { + output: out, + deferred_network_approval: first_deferred_network_approval, + }) } - Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output, + network_policy_decision, + }))) => { + let network_approval_context = if has_managed_network_requirements { + network_policy_decision + .as_ref() + .and_then(network_approval_context_from_payload) + } else { + None + }; + if network_policy_decision.is_some() && network_approval_context.is_none() { + return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output, + network_policy_decision, + }))); + } if !tool.escalate_on_failure() { return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, + network_policy_decision, }))); } // Under `Never` or `OnRequest`, do not retry without sandbox; surface a concise // sandbox denial that preserves the original output. if !tool.wants_no_sandbox_approval(approval_policy) { - return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { - output, - }))); + let allow_on_request_network_prompt = + matches!(approval_policy, AskForApproval::OnRequest) + && network_approval_context.is_some() + && matches!( + default_exec_approval_requirement( + approval_policy, + &turn_ctx.sandbox_policy + ), + ExecApprovalRequirement::NeedsApproval { .. } + ); + if !allow_on_request_network_prompt { + return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output, + network_policy_decision, + }))); + } } + let retry_reason = + if let Some(network_approval_context) = network_approval_context.as_ref() { + format!( + "Network access to \"{}\" is blocked by policy.", + network_approval_context.host + ) + } else { + build_denial_reason_from_output(output.as_ref()) + }; // Ask for approval before retrying with the escalated sandbox. - if !tool.should_bypass_approval(approval_policy, already_approved) { - let reason_msg = build_denial_reason_from_output(output.as_ref()); + let bypass_retry_approval = tool + .should_bypass_approval(approval_policy, already_approved) + && network_approval_context.is_none(); + if !bypass_retry_approval { let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, - retry_reason: Some(reason_msg), + retry_reason: Some(retry_reason), + network_approval_context: network_approval_context.clone(), }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -172,9 +291,20 @@ impl ToolOrchestrator { }; // Second attempt. - (*tool).run(req, &escalated_attempt, tool_ctx).await + let (retry_result, retry_deferred_network_approval) = Self::run_attempt( + tool, + req, + tool_ctx, + &escalated_attempt, + has_managed_network_requirements, + ) + .await; + retry_result.map(|output| OrchestratorRunResult { + output, + deferred_network_approval: retry_deferred_network_approval, + }) } - other => other, + Err(err) => Err(err), } } } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 8ab702c0c2..8006c80722 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -4,6 +4,7 @@ use std::time::Duration; use std::time::Instant; use crate::client_common::tools::ToolSpec; +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::protocol::SandboxPolicy; use crate::sandbox_tags::sandbox_tag; @@ -14,6 +15,7 @@ use async_trait::async_trait; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterToolUse; use codex_hooks::HookPayload; +use codex_hooks::HookResult; use codex_hooks::HookToolInput; use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; @@ -88,6 +90,10 @@ impl ToolRegistry { sandbox_tag( &invocation.turn.sandbox_policy, invocation.turn.windows_sandbox_level, + invocation + .turn + .features + .enabled(Feature::UseLinuxSandboxBwrap), ), ), ( @@ -167,7 +173,7 @@ impl ToolRegistry { Ok((preview, success)) => (preview.clone(), *success), Err(err) => (err.to_string(), false), }; - dispatch_after_tool_use_hook(AfterToolUseHookDispatch { + let hook_abort_error = dispatch_after_tool_use_hook(AfterToolUseHookDispatch { invocation: &invocation, output_preview, success, @@ -177,6 +183,10 @@ impl ToolRegistry { }) .await; + if let Some(err) = hook_abort_error { + return Err(err); + } + match result { Ok(_) => { let mut guard = output_cell.lock().await; @@ -334,12 +344,14 @@ struct AfterToolUseHookDispatch<'a> { mutating: bool, } -async fn dispatch_after_tool_use_hook(dispatch: AfterToolUseHookDispatch<'_>) { +async fn dispatch_after_tool_use_hook( + dispatch: AfterToolUseHookDispatch<'_>, +) -> Option { let AfterToolUseHookDispatch { invocation, .. } = dispatch; let session = invocation.session.as_ref(); let turn = invocation.turn.as_ref(); let tool_input = HookToolInput::from(&invocation.payload); - session + let hook_outcomes = session .hooks() .dispatch(HookPayload { session_id: session.conversation_id, @@ -356,12 +368,46 @@ async fn dispatch_after_tool_use_hook(dispatch: AfterToolUseHookDispatch<'_>) { success: dispatch.success, duration_ms: u64::try_from(dispatch.duration.as_millis()).unwrap_or(u64::MAX), mutating: dispatch.mutating, - sandbox: sandbox_tag(&turn.sandbox_policy, turn.windows_sandbox_level) - .to_string(), + sandbox: sandbox_tag( + &turn.sandbox_policy, + turn.windows_sandbox_level, + turn.features.enabled(Feature::UseLinuxSandboxBwrap), + ) + .to_string(), sandbox_policy: sandbox_policy_tag(&turn.sandbox_policy).to_string(), output_preview: dispatch.output_preview.clone(), }, }, }) .await; + + for hook_outcome in hook_outcomes { + let hook_name = hook_outcome.hook_name; + match hook_outcome.result { + HookResult::Success => {} + HookResult::FailedContinue(error) => { + warn!( + call_id = %invocation.call_id, + tool_name = %invocation.tool_name, + hook_name = %hook_name, + error = %error, + "after_tool_use hook failed; continuing" + ); + } + HookResult::FailedAbort(error) => { + warn!( + call_id = %invocation.call_id, + tool_name = %invocation.tool_name, + hook_name = %hook_name, + error = %error, + "after_tool_use hook failed; aborting operation" + ); + return Some(FunctionCallError::Fatal(format!( + "after_tool_use hook '{hook_name}' failed and aborted operation: {error}" + ))); + } + } + } + + None } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index b3d7b32921..92c8f39223 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -57,7 +57,12 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( command: &[String], session_shell: &Shell, cwd: &Path, + explicit_env_overrides: &HashMap, ) -> Vec { + if cfg!(windows) { + return command.to_vec(); + } + let Some(snapshot) = session_shell.shell_snapshot() else { return command.to_vec(); }; @@ -95,24 +100,78 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( .iter() .map(|arg| format!(" '{}'", shell_single_quote(arg))) .collect::(); - let rewritten_script = format!( - "if . '{snapshot_path}' >/dev/null 2>&1; then :; fi; exec '{original_shell}' -c '{original_script}'{trailing_args}" - ); + let (override_captures, override_exports) = build_override_exports(explicit_env_overrides); + let rewritten_script = if override_exports.is_empty() { + format!( + "if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}" + ) + } else { + format!( + "{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}" + ) + }; vec![shell_path.to_string(), "-c".to_string(), rewritten_script] } +fn build_override_exports(explicit_env_overrides: &HashMap) -> (String, String) { + let mut keys = explicit_env_overrides + .keys() + .filter(|key| is_valid_shell_variable_name(key)) + .collect::>(); + keys.sort_unstable(); + + if keys.is_empty() { + return (String::new(), String::new()); + } + + let captures = keys + .iter() + .enumerate() + .map(|(idx, key)| { + format!( + "__CODEX_SNAPSHOT_OVERRIDE_SET_{idx}=\"${{{key}+x}}\"\n__CODEX_SNAPSHOT_OVERRIDE_{idx}=\"${{{key}-}}\"" + ) + }) + .collect::>() + .join("\n"); + let restores = keys + .iter() + .enumerate() + .map(|(idx, key)| { + format!( + "if [ -n \"${{__CODEX_SNAPSHOT_OVERRIDE_SET_{idx}}}\" ]; then export {key}=\"${{__CODEX_SNAPSHOT_OVERRIDE_{idx}}}\"; else unset {key}; fi" + ) + }) + .collect::>() + .join("\n"); + + (captures, restores) +} + +fn is_valid_shell_variable_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + fn shell_single_quote(input: &str) -> String { input.replace('\'', r#"'"'"'"#) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use crate::shell::ShellType; use crate::shell_snapshot::ShellSnapshot; use pretty_assertions::assert_eq; use std::path::PathBuf; + use std::process::Command; use std::sync::Arc; use tempfile::tempdir; use tokio::sync::watch; @@ -151,7 +210,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/zsh"); assert_eq!(rewritten[1], "-c"); @@ -176,7 +240,12 @@ mod tests { "echo 'hello'".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert!(rewritten[2].contains(r#"exec '/bin/bash' -c 'echo '"'"'hello'"'"''"#)); } @@ -198,7 +267,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/bash"); assert_eq!(rewritten[1], "-c"); @@ -223,7 +297,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/sh"); assert_eq!(rewritten[1], "-c"); @@ -250,7 +329,12 @@ mod tests { "arg1".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, dir.path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); assert!( rewritten[2].contains( @@ -276,7 +360,12 @@ mod tests { "echo hello".to_string(), ]; - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &command_cwd, + &HashMap::new(), + ); assert_eq!(rewritten, command); } @@ -299,11 +388,214 @@ mod tests { ]; let command_cwd = dir.path().join("."); - let rewritten = maybe_wrap_shell_lc_with_snapshot(&command, &session_shell, &command_cwd); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &command_cwd, + &HashMap::new(), + ); assert_eq!(rewritten[0], "/bin/zsh"); assert_eq!(rewritten[1], "-c"); assert!(rewritten[2].contains("if . '")); assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'")); } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport TEST_ENV_SNAPSHOT=global\nexport SNAPSHOT_ONLY=from_snapshot\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s|%s' \"$TEST_ENV_SNAPSHOT\" \"${SNAPSHOT_ONLY-unset}\"".to_string(), + ]; + let explicit_env_overrides = + HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("TEST_ENV_SNAPSHOT", "worktree") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "worktree|from_snapshot" + ); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "/snapshot/bin"); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let explicit_env_overrides = + HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("PATH", "/worktree/bin") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin"); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport OPENAI_API_KEY='snapshot-value'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$OPENAI_API_KEY\"".to_string(), + ]; + let explicit_env_overrides = HashMap::from([( + "OPENAI_API_KEY".to_string(), + "super-secret-value".to_string(), + )]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + + assert!(!rewritten[2].contains("super-secret-value")); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("OPENAI_API_KEY", "super-secret-value") + .output() + .expect("run rewritten command"); + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "super-secret-value" + ); + } + + #[test] + fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport CODEX_TEST_UNSET_OVERRIDE='snapshot-value'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path, + dir.path().to_path_buf(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "if [ \"${CODEX_TEST_UNSET_OVERRIDE+x}\" = x ]; then printf 'set:%s' \"$CODEX_TEST_UNSET_OVERRIDE\"; else printf 'unset'; fi".to_string(), + ]; + let explicit_env_overrides = HashMap::from([( + "CODEX_TEST_UNSET_OVERRIDE".to_string(), + "worktree-value".to_string(), + )]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + dir.path(), + &explicit_env_overrides, + ); + + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env_remove("CODEX_TEST_UNSET_OVERRIDE") + .output() + .expect("run rewritten command"); + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); + } } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index fd076f005e..3d7a61ef77 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -11,6 +11,8 @@ use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; +use crate::tools::network_approval::NetworkApprovalMode; +use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; @@ -35,6 +37,7 @@ pub struct ShellRequest { pub cwd: PathBuf, pub timeout_ms: Option, pub env: std::collections::HashMap, + pub explicit_env_overrides: std::collections::HashMap, pub network: Option, pub sandbox_permissions: SandboxPermissions, pub justification: Option, @@ -109,6 +112,7 @@ impl Approvable for ShellRuntime { command, cwd, reason, + ctx.network_approval_context.clone(), req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), @@ -141,6 +145,20 @@ impl Approvable for ShellRuntime { } impl ToolRuntime for ShellRuntime { + fn network_approval_spec( + &self, + req: &ShellRequest, + _ctx: &ToolCtx<'_>, + ) -> Option { + req.network.as_ref()?; + Some(NetworkApprovalSpec { + command: req.command.clone(), + cwd: req.cwd.clone(), + network: req.network.clone(), + mode: NetworkApprovalMode::Immediate, + }) + } + async fn run( &mut self, req: &ShellRequest, @@ -149,8 +167,12 @@ impl ToolRuntime for ShellRuntime { ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); - let command = - maybe_wrap_shell_lc_with_snapshot(base_command, session_shell.as_ref(), &req.cwd); + let command = maybe_wrap_shell_lc_with_snapshot( + base_command, + session_shell.as_ref(), + &req.cwd, + &req.explicit_env_overrides, + ); let command = if matches!(session_shell.shell_type, ShellType::PowerShell) && ctx.session.features().enabled(Feature::PowershellUtf8) { @@ -167,9 +189,10 @@ impl ToolRuntime for ShellRuntime { req.sandbox_permissions, req.justification.clone(), )?; - let env = attempt + let mut env = attempt .env_for(spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; + env.network_attempt_id = ctx.network_attempt_id.clone(); let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index f3bd2e64b1..89b159c5b8 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -12,6 +12,8 @@ use crate::features::Feature; use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; +use crate::tools::network_approval::NetworkApprovalMode; +use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; @@ -39,6 +41,7 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, + pub explicit_env_overrides: HashMap, pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, @@ -110,6 +113,7 @@ impl Approvable for UnifiedExecRuntime<'_> { command, cwd, reason, + ctx.network_approval_context.clone(), req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), @@ -145,6 +149,20 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn network_approval_spec( + &self, + req: &UnifiedExecRequest, + _ctx: &ToolCtx<'_>, + ) -> Option { + req.network.as_ref()?; + Some(NetworkApprovalSpec { + command: req.command.clone(), + cwd: req.cwd.clone(), + network: req.network.clone(), + mode: NetworkApprovalMode::Deferred, + }) + } + async fn run( &mut self, req: &UnifiedExecRequest, @@ -153,8 +171,12 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); - let command = - maybe_wrap_shell_lc_with_snapshot(base_command, session_shell.as_ref(), &req.cwd); + let command = maybe_wrap_shell_lc_with_snapshot( + base_command, + session_shell.as_ref(), + &req.cwd, + &req.explicit_env_overrides, + ); let command = if matches!(session_shell.shell_type, ShellType::PowerShell) && ctx.session.features().enabled(Feature::PowershellUtf8) { @@ -165,7 +187,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt let mut env = req.env.clone(); if let Some(network) = req.network.as_ref() { - network.apply_to_env(&mut env); + network.apply_to_env_for_attempt(&mut env, ctx.network_attempt_id.as_deref()); } let spec = build_command_spec( &command, @@ -186,6 +208,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt UnifiedExecError::SandboxDenied { output, .. } => { ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), + network_policy_decision: None, })) } other => ToolError::Rejected(other.to_string()), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 3671760844..04dc10c3e1 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -12,8 +12,10 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; +use crate::tools::network_approval::NetworkApprovalSpec; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use std::collections::HashMap; @@ -110,6 +112,7 @@ pub(crate) struct ApprovalCtx<'a> { pub turn: &'a TurnContext, pub call_id: &'a str, pub retry_reason: Option, + pub network_approval_context: Option, } // Specifies what tool orchestrator should do with a given tool call. @@ -252,6 +255,7 @@ pub(crate) struct ToolCtx<'a> { pub turn: &'a TurnContext, pub call_id: String, pub tool_name: String, + pub network_attempt_id: Option, } #[derive(Debug)] @@ -261,6 +265,10 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { + fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx<'_>) -> Option { + None + } + async fn run( &mut self, req: &Req, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 06181ae0fb..6ea7edd00f 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,18 +1,19 @@ -use crate::agent::AgentRole; use crate::client_common::tools::FreeformTool; use crate::client_common::tools::FreeformToolFormat; use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; +use crate::config::AgentRoleConfig; use crate::features::Feature; use crate::features::Features; use crate::mcp_connection_manager::ToolInfo; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT; +use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; -use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::collab::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::config_types::WebSearchMode; @@ -36,12 +37,12 @@ pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, pub apply_patch_tool_type: Option, pub web_search_mode: Option, + pub agent_roles: BTreeMap, pub search_tool: bool, pub js_repl_enabled: bool, pub js_repl_tools_only: bool, pub collab_tools: bool, pub collaboration_modes_tools: bool, - pub request_rule_enabled: bool, pub experimental_supported_tools: Vec, } @@ -64,7 +65,6 @@ impl ToolsConfig { include_js_repl && features.enabled(Feature::JsReplToolsOnly); let include_collab_tools = features.enabled(Feature::Collab); let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes); - let request_rule_enabled = features.enabled(Feature::RequestRule); let include_search_tool = features.enabled(Feature::Apps); let shell_type = if !features.enabled(Feature::ShellTool) { @@ -96,15 +96,20 @@ impl ToolsConfig { shell_type, apply_patch_tool_type, web_search_mode: *web_search_mode, + agent_roles: BTreeMap::new(), search_tool: include_search_tool, js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, collab_tools: include_collab_tools, collaboration_modes_tools: include_collaboration_modes_tools, - request_rule_enabled, experimental_supported_tools: model_info.experimental_supported_tools.clone(), } } + + pub fn with_agent_roles(mut self, agent_roles: BTreeMap) -> Self { + self.agent_roles = agent_roles; + self + } } pub(crate) fn filter_tools_for_model(tools: Vec, config: &ToolsConfig) -> Vec { @@ -174,7 +179,7 @@ impl From for AdditionalProperties { } } -fn create_approval_parameters(include_prefix_rule: bool) -> BTreeMap { +fn create_approval_parameters() -> BTreeMap { let mut properties = BTreeMap::from([ ( "sandbox_permissions".to_string(), @@ -200,23 +205,22 @@ fn create_approval_parameters(include_prefix_rule: bool) -> BTreeMap ToolSpec { +fn create_exec_command_tool() -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -274,7 +278,7 @@ fn create_exec_command_tool(include_prefix_rule: bool) -> ToolSpec { }, ), ]); - properties.extend(create_approval_parameters(include_prefix_rule)); + properties.extend(create_approval_parameters()); ToolSpec::Function(ResponsesApiTool { name: "exec_command".to_string(), @@ -337,7 +341,7 @@ fn create_write_stdin_tool() -> ToolSpec { }) } -fn create_shell_tool(include_prefix_rule: bool) -> ToolSpec { +fn create_shell_tool() -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -359,7 +363,7 @@ fn create_shell_tool(include_prefix_rule: bool) -> ToolSpec { }, ), ]); - properties.extend(create_approval_parameters(include_prefix_rule)); + properties.extend(create_approval_parameters()); let description = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. @@ -390,7 +394,7 @@ Examples of valid command strings: }) } -fn create_shell_command_tool(include_prefix_rule: bool) -> ToolSpec { +fn create_shell_command_tool() -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -422,7 +426,7 @@ fn create_shell_command_tool(include_prefix_rule: bool) -> ToolSpec { }, ), ]); - properties.extend(create_approval_parameters(include_prefix_rule)); + properties.extend(create_approval_parameters()); let description = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. @@ -526,7 +530,7 @@ fn create_collab_input_items_schema() -> JsonSchema { } } -fn create_spawn_agent_tool() -> ToolSpec { +fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { let properties = BTreeMap::from([ ( "message".to_string(), @@ -541,9 +545,8 @@ fn create_spawn_agent_tool() -> ToolSpec { ( "agent_type".to_string(), JsonSchema::String { - description: Some(format!( - "Optional agent type ({}). Use an explicit type when delegating.", - AgentRole::enum_values().join(", ") + description: Some(crate::agent::role::spawn_tool_spec::build( + &config.agent_roles, )), }, ), @@ -913,7 +916,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str()); ToolSpec::Function(ResponsesApiTool { - name: "search_tool_bm25".to_string(), + name: SEARCH_TOOL_BM25_TOOL_NAME.to_string(), description, strict: false, parameters: JsonSchema::Object { @@ -1408,7 +1411,6 @@ pub(crate) fn build_specs( dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { use crate::tools::handlers::ApplyPatchHandler; - use crate::tools::handlers::CollabHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; @@ -1416,6 +1418,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; + use crate::tools::handlers::MultiAgentHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestUserInputHandler; @@ -1445,19 +1448,13 @@ pub(crate) fn build_specs( match &config.shell_type { ConfigShellToolType::Default => { - builder.push_spec_with_parallel_support( - create_shell_tool(config.request_rule_enabled), - true, - ); + builder.push_spec_with_parallel_support(create_shell_tool(), true); } ConfigShellToolType::Local => { builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true); } ConfigShellToolType::UnifiedExec => { - builder.push_spec_with_parallel_support( - create_exec_command_tool(config.request_rule_enabled), - true, - ); + builder.push_spec_with_parallel_support(create_exec_command_tool(), true); builder.push_spec(create_write_stdin_tool()); builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); @@ -1466,10 +1463,7 @@ pub(crate) fn build_specs( // Do nothing. } ConfigShellToolType::ShellCommand => { - builder.push_spec_with_parallel_support( - create_shell_command_tool(config.request_rule_enabled), - true, - ); + builder.push_spec_with_parallel_support(create_shell_command_tool(), true); } } @@ -1481,12 +1475,14 @@ pub(crate) fn build_specs( builder.register_handler("shell_command", shell_command_handler); } - builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); - builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); - builder.push_spec_with_parallel_support(create_read_mcp_resource_tool(), true); - builder.register_handler("list_mcp_resources", mcp_resource_handler.clone()); - builder.register_handler("list_mcp_resource_templates", mcp_resource_handler.clone()); - builder.register_handler("read_mcp_resource", mcp_resource_handler); + if mcp_tools.is_some() { + builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); + builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); + builder.push_spec_with_parallel_support(create_read_mcp_resource_tool(), true); + builder.register_handler("list_mcp_resources", mcp_resource_handler.clone()); + builder.register_handler("list_mcp_resource_templates", mcp_resource_handler.clone()); + builder.register_handler("read_mcp_resource", mcp_resource_handler); + } builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); @@ -1507,7 +1503,7 @@ pub(crate) fn build_specs( && let Some(app_tools) = app_tools { builder.push_spec_with_parallel_support(create_search_tool_bm25_tool(&app_tools), true); - builder.register_handler("search_tool_bm25", search_tool_handler); + builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler); } if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { @@ -1577,17 +1573,17 @@ pub(crate) fn build_specs( builder.register_handler("view_image", view_image_handler); if config.collab_tools { - let collab_handler = Arc::new(CollabHandler); - builder.push_spec(create_spawn_agent_tool()); + let multi_agent_handler = Arc::new(MultiAgentHandler); + builder.push_spec(create_spawn_agent_tool(config)); builder.push_spec(create_send_input_tool()); builder.push_spec(create_resume_agent_tool()); builder.push_spec(create_wait_tool()); builder.push_spec(create_close_agent_tool()); - builder.register_handler("spawn_agent", collab_handler.clone()); - builder.register_handler("send_input", collab_handler.clone()); - builder.register_handler("resume_agent", collab_handler.clone()); - builder.register_handler("wait", collab_handler.clone()); - builder.register_handler("close_agent", collab_handler); + builder.register_handler("spawn_agent", multi_agent_handler.clone()); + builder.register_handler("send_input", multi_agent_handler.clone()); + builder.register_handler("resume_agent", multi_agent_handler.clone()); + builder.register_handler("wait", multi_agent_handler.clone()); + builder.register_handler("close_agent", multi_agent_handler); } if let Some(mcp_tools) = mcp_tools { @@ -1833,11 +1829,8 @@ mod tests { // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ - create_exec_command_tool(true), + create_exec_command_tool(), create_write_stdin_tool(), - create_list_mcp_resources_tool(), - create_list_mcp_resource_templates_tool(), - create_read_mcp_resource_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), create_apply_patch_freeform_tool(), @@ -2042,7 +2035,7 @@ mod tests { features, web_search_mode, }); - let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); assert_eq!(&tool_names, &expected_tools,); } @@ -2109,6 +2102,53 @@ mod tests { ); } + #[test] + fn mcp_resource_tools_are_hidden_without_mcp_servers() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert!( + !tools.iter().any(|tool| matches!( + tool.spec.name(), + "list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource" + )), + "MCP resource tools should be omitted when no MCP servers are configured" + ); + } + + #[test] + fn mcp_resource_tools_are_included_when_mcp_servers_are_present() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); + + assert_contains_tool_names( + &tools, + &[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + ], + ); + } + #[test] fn test_build_specs_gpt5_codex_default() { let mut features = Features::with_defaults(); @@ -2119,9 +2159,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2141,9 +2178,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2165,9 +2199,6 @@ mod tests { &[ "exec_command", "write_stdin", - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2189,9 +2220,6 @@ mod tests { &[ "exec_command", "write_stdin", - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2211,9 +2239,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2233,9 +2258,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2255,9 +2277,6 @@ mod tests { Some(WebSearchMode::Cached), "shell", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "web_search", @@ -2276,9 +2295,6 @@ mod tests { Some(WebSearchMode::Cached), "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2300,9 +2316,6 @@ mod tests { &[ "exec_command", "write_stdin", - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -2579,7 +2592,7 @@ mod tests { ) .build(); - let search_tool = find_tool(&tools, "search_tool_bm25"); + let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else { panic!("expected function tool"); }; @@ -2799,7 +2812,7 @@ mod tests { #[test] fn test_shell_tool() { - let tool = super::create_shell_tool(true); + let tool = super::create_shell_tool(); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool @@ -2829,7 +2842,7 @@ Examples of valid command strings: #[test] fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(true); + let tool = super::create_shell_command_tool(); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index dd4d717449..0814897d91 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -12,6 +12,9 @@ use crate::git_info::get_git_remote_urls_assume_git_repo; use crate::git_info::get_git_repo_root; use crate::git_info::get_has_changes; use crate::git_info::get_head_commit_hash; +use crate::sandbox_tags::sandbox_tag; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::SandboxPolicy; #[derive(Clone, Debug, Default)] struct WorkspaceGitMetadata { @@ -124,8 +127,22 @@ pub(crate) struct TurnMetadataState { } impl TurnMetadataState { - pub(crate) fn new(turn_id: String, cwd: PathBuf, sandbox: Option) -> Self { + pub(crate) fn new( + turn_id: String, + cwd: PathBuf, + sandbox_policy: &SandboxPolicy, + windows_sandbox_level: WindowsSandboxLevel, + use_linux_sandbox_bwrap: bool, + ) -> Self { let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned()); + let sandbox = Some( + sandbox_tag( + sandbox_policy, + windows_sandbox_level, + use_linux_sandbox_bwrap, + ) + .to_string(), + ); let base_metadata = build_turn_metadata_bag(Some(turn_id), sandbox, None, None); let base_header = base_metadata .to_header_value() @@ -281,4 +298,52 @@ mod tests { Some(false) ); } + + #[test] + fn turn_metadata_state_respects_linux_bubblewrap_toggle() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().to_path_buf(); + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + + let without_bubblewrap = TurnMetadataState::new( + "turn-a".to_string(), + cwd.clone(), + &sandbox_policy, + WindowsSandboxLevel::Disabled, + false, + ); + let with_bubblewrap = TurnMetadataState::new( + "turn-b".to_string(), + cwd, + &sandbox_policy, + WindowsSandboxLevel::Disabled, + true, + ); + + let without_bubblewrap_header = without_bubblewrap + .current_header_value() + .expect("without_bubblewrap_header"); + let with_bubblewrap_header = with_bubblewrap + .current_header_value() + .expect("with_bubblewrap_header"); + + let without_bubblewrap_json: Value = + serde_json::from_str(&without_bubblewrap_header).expect("without_bubblewrap_json"); + let with_bubblewrap_json: Value = + serde_json::from_str(&with_bubblewrap_header).expect("with_bubblewrap_json"); + + let without_bubblewrap_sandbox = without_bubblewrap_json + .get("sandbox") + .and_then(Value::as_str); + let with_bubblewrap_sandbox = with_bubblewrap_json.get("sandbox").and_then(Value::as_str); + + let expected_with_bubblewrap = + sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled, true); + assert_eq!(with_bubblewrap_sandbox, Some(expected_with_bubblewrap)); + + if cfg!(target_os = "linux") { + assert_eq!(with_bubblewrap_sandbox, Some("linux_bubblewrap")); + assert_ne!(with_bubblewrap_sandbox, without_bubblewrap_sandbox); + } + } } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 1fbb1e8f6d..772baab00b 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -139,6 +139,43 @@ pub(crate) fn spawn_exit_watcher( }); } +pub(crate) fn spawn_network_denial_watcher( + process: Arc, + session: Arc, + process_id: String, + network_attempt_id: String, +) { + let exit_token = process.cancellation_token(); + tokio::spawn(async move { + let mut poll = tokio::time::interval(Duration::from_millis(100)); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = exit_token.cancelled() => { + break; + } + _ = poll.tick() => { + if session + .services + .network_approval + .take_user_denial_outcome(&network_attempt_id) + .await + { + process.terminate(); + session + .services + .unified_exec_manager + .release_process_id(&process_id) + .await; + break; + } + } + } + } + }); +} + async fn process_chunk( pending: &mut Vec, transcript: &Arc>, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 84785270f8..a907f348f2 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -25,6 +25,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Weak; use std::time::Duration; use codex_network_proxy::NetworkProxy; @@ -144,6 +145,8 @@ struct ProcessEntry { process_id: String, command: Vec, tty: bool, + network_attempt_id: Option, + session: Weak, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 251a4910ba..6896df14b4 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -19,6 +19,9 @@ use crate::sandboxing::ExecRequest; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::events::ToolEventStage; +use crate::tools::network_approval::DeferredNetworkApproval; +use crate::tools::network_approval::deferred_rejection_message; +use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -40,6 +43,7 @@ use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES; use crate::unified_exec::WriteStdinRequest; use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec; use crate::unified_exec::async_watcher::spawn_exit_watcher; +use crate::unified_exec::async_watcher::spawn_network_denial_watcher; use crate::unified_exec::async_watcher::start_streaming_output; use crate::unified_exec::clamp_yield_time; use crate::unified_exec::generate_chunk_id; @@ -130,8 +134,25 @@ impl UnifiedExecProcessManager { } pub(crate) async fn release_process_id(&self, process_id: &str) { - let mut store = self.process_store.lock().await; - store.remove(process_id); + let removed = { + let mut store = self.process_store.lock().await; + store.remove(process_id) + }; + if let Some(entry) = removed { + Self::unregister_network_attempt_for_entry(&entry).await; + } + } + + async fn unregister_network_attempt_for_entry(entry: &ProcessEntry) { + if let Some(attempt_id) = entry.network_attempt_id.as_deref() + && let Some(session) = entry.session.upgrade() + { + session + .services + .network_approval + .unregister_attempt(attempt_id) + .await; + } } pub(crate) async fn exec_command( @@ -143,13 +164,14 @@ impl UnifiedExecProcessManager { .workdir .clone() .unwrap_or_else(|| context.turn.cwd.clone()); - let process = self .open_session_with_sandbox(&request, cwd.clone(), context) .await; - let process = match process { - Ok(process) => Arc::new(process), + let (process, mut deferred_network_approval) = match process { + Ok((process, deferred_network_approval)) => { + (Arc::new(process), deferred_network_approval) + } Err(err) => { self.release_process_id(&request.process_id).await; return Err(err); @@ -172,7 +194,6 @@ impl UnifiedExecProcessManager { emitter.emit(event_ctx, ToolEventStage::Begin).await; start_streaming_output(&process, context, Arc::clone(&transcript)); - let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); @@ -205,6 +226,7 @@ impl UnifiedExecProcessManager { let has_exited = process.has_exited() || exit_code.is_some(); let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); + if has_exited { // Short‑lived command: emit ExecCommandEnd immediately using the // same helper as the background watcher, so all end events share @@ -215,7 +237,7 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), request.command.clone(), - cwd, + cwd.clone(), Some(process_id), Arc::clone(&transcript), output.clone(), @@ -225,12 +247,45 @@ impl UnifiedExecProcessManager { .await; self.release_process_id(&request.process_id).await; + if let Some(deferred) = deferred_network_approval.as_ref() + && let Some(message) = + deferred_rejection_message(context.session.as_ref(), deferred).await + { + finish_deferred_network_approval( + context.session.as_ref(), + deferred_network_approval.take(), + ) + .await; + return Err(UnifiedExecError::create_process(message)); + } + finish_deferred_network_approval( + context.session.as_ref(), + deferred_network_approval.take(), + ) + .await; process.check_for_sandbox_denial_with_text(&text).await?; } else { + if let Some(deferred) = deferred_network_approval.as_ref() + && let Some(message) = + deferred_rejection_message(context.session.as_ref(), deferred).await + { + process.terminate(); + finish_deferred_network_approval( + context.session.as_ref(), + deferred_network_approval.take(), + ) + .await; + self.release_process_id(&request.process_id).await; + return Err(UnifiedExecError::create_process(message)); + } + // Long‑lived command: persist the process so write_stdin can reuse // it, and register a background watcher that will emit // ExecCommandEnd when the PTY eventually exits (even if no further // tool calls are made). + let network_attempt_id = deferred_network_approval + .as_ref() + .map(|deferred| deferred.attempt_id().to_string()); self.store_process( Arc::clone(&process), context, @@ -239,6 +294,7 @@ impl UnifiedExecProcessManager { start, process_id, request.tty, + network_attempt_id, Arc::clone(&transcript), ) .await; @@ -358,29 +414,35 @@ impl UnifiedExecProcessManager { } async fn refresh_process_state(&self, process_id: &str) -> ProcessStatus { - let mut store = self.process_store.lock().await; - let Some(entry) = store.processes.get(process_id) else { - return ProcessStatus::Unknown; - }; - - let exit_code = entry.process.exit_code(); - let process_id = entry.process_id.clone(); - - if entry.process.has_exited() { - let Some(entry) = store.remove(&process_id) else { + let status = { + let mut store = self.process_store.lock().await; + let Some(entry) = store.processes.get(process_id) else { return ProcessStatus::Unknown; }; - ProcessStatus::Exited { - exit_code, - entry: Box::new(entry), - } - } else { - ProcessStatus::Alive { - exit_code, - call_id: entry.call_id.clone(), - process_id, + + let exit_code = entry.process.exit_code(); + let process_id = entry.process_id.clone(); + + if entry.process.has_exited() { + let Some(entry) = store.remove(&process_id) else { + return ProcessStatus::Unknown; + }; + ProcessStatus::Exited { + exit_code, + entry: Box::new(entry), + } + } else { + ProcessStatus::Alive { + exit_code, + call_id: entry.call_id.clone(), + process_id, + } } + }; + if let ProcessStatus::Exited { entry, .. } = &status { + Self::unregister_network_attempt_for_entry(entry).await; } + status } async fn prepare_process_handles( @@ -437,22 +499,32 @@ impl UnifiedExecProcessManager { started_at: Instant, process_id: String, tty: bool, + network_attempt_id: Option, transcript: Arc>, ) { + let network_attempt_id_for_watcher = network_attempt_id.clone(); let entry = ProcessEntry { process: Arc::clone(&process), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), tty, + network_attempt_id, + session: Arc::downgrade(&context.session), last_used: started_at, }; - let number_processes = { + let (number_processes, pruned_entry) = { let mut store = self.process_store.lock().await; - Self::prune_processes_if_needed(&mut store); + let pruned_entry = Self::prune_processes_if_needed(&mut store); store.processes.insert(process_id.clone(), entry); - store.processes.len() + (store.processes.len(), pruned_entry) }; + // prune_processes_if_needed runs while holding process_store; do async + // network-approval cleanup only after dropping that lock. + if let Some(pruned_entry) = pruned_entry { + Self::unregister_network_attempt_for_entry(&pruned_entry).await; + pruned_entry.process.terminate(); + } if number_processes >= WARNING_UNIFIED_EXEC_PROCESSES { context @@ -471,10 +543,21 @@ impl UnifiedExecProcessManager { context.call_id.clone(), command.to_vec(), cwd, - process_id, + process_id.clone(), transcript, started_at, ); + + if context.turn.config.managed_network_requirements_enabled() + && let Some(network_attempt_id) = network_attempt_id_for_watcher + { + spawn_network_denial_watcher( + Arc::clone(&process), + Arc::clone(&context.session), + process_id, + network_attempt_id, + ); + } } pub(crate) async fn open_session_with_exec_env( @@ -516,7 +599,7 @@ impl UnifiedExecProcessManager { request: &ExecCommandRequest, cwd: PathBuf, context: &UnifiedExecContext, - ) -> Result { + ) -> Result<(UnifiedExecProcess, Option), UnifiedExecError> { let env = apply_unified_exec_env(create_env( &context.turn.shell_environment_policy, Some(context.session.conversation_id), @@ -539,6 +622,7 @@ impl UnifiedExecProcessManager { command: request.command.clone(), cwd, env, + explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(), network: request.network.clone(), tty: request.tty, sandbox_permissions: request.sandbox_permissions, @@ -550,6 +634,7 @@ impl UnifiedExecProcessManager { turn: context.turn.as_ref(), call_id: context.call_id.clone(), tool_name: "exec_command".to_string(), + network_attempt_id: None, }; orchestrator .run( @@ -560,6 +645,7 @@ impl UnifiedExecProcessManager { context.turn.approval_policy, ) .await + .map(|result| (result.output, result.deferred_network_approval)) .map_err(|e| UnifiedExecError::create_process(format!("{e:?}"))) } @@ -643,9 +729,9 @@ impl UnifiedExecProcessManager { collected } - fn prune_processes_if_needed(store: &mut ProcessStore) -> bool { + fn prune_processes_if_needed(store: &mut ProcessStore) -> Option { if store.processes.len() < MAX_UNIFIED_EXEC_PROCESSES { - return false; + return None; } let meta: Vec<(String, Instant, bool)> = store @@ -655,13 +741,10 @@ impl UnifiedExecProcessManager { .collect(); if let Some(process_id) = Self::process_id_to_prune_from_meta(&meta) { - if let Some(entry) = store.remove(&process_id) { - entry.process.terminate(); - } - return true; + return store.remove(&process_id); } - false + None } // Centralized pruning policy so we can easily swap strategies later. @@ -706,6 +789,7 @@ impl UnifiedExecProcessManager { }; for entry in entries { + Self::unregister_network_attempt_for_entry(&entry).await; entry.process.terminate(); } } diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index ce4268177d..05667b7356 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -2,7 +2,7 @@ Searches over apps tool metadata with BM25 and exposes matching tools for the next model call. -When `search_tool_bm25` is available, apps tools (`mcp__codex_apps__...`) are hidden until you search for them. +MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`). Follow this workflow: @@ -10,8 +10,8 @@ Follow this workflow: - `query` (required): focused terms that describe the capability you need. - `limit` (optional): maximum number of tools to return (default `8`). 2. Use the returned `tools` list to decide which Apps tools are relevant. -3. Matching tools are added to available `tools` and available for the remainder of the current turn. -4. Repeated searches in the same turn are additive: new matches are unioned into `tools`. +3. Matching tools are added to available `tools` and available for the remainder of the current session/thread. +4. Repeated searches in the same session/thread are additive: new matches are unioned into `tools`. Notes: - Core tools remain available without searching. @@ -23,8 +23,6 @@ Notes: - `title` - `description` - `connector_name` - - `connector_id` - input schema property keys (`input_keys`) -- Use `search_tool_bm25` when the user asks to work with one of apps ({{app_names}}) and the exact tool name is not already known. -- If the needed app is already explicit in the prompt (for example an `apps://...` mention) or already present in the current `tools` list, you can call that tool directly. -- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows). +- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly. +- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}. diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs new file mode 100644 index 0000000000..c562e390e1 --- /dev/null +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -0,0 +1,158 @@ +use anyhow::Result; +use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::path_regex; + +const CONNECTOR_ID: &str = "calendar"; +const CONNECTOR_NAME: &str = "Calendar"; +const PROTOCOL_VERSION: &str = "2025-11-25"; +const SERVER_NAME: &str = "codex-apps-test"; +const SERVER_VERSION: &str = "1.0.0"; + +#[derive(Clone)] +pub struct AppsTestServer { + pub chatgpt_base_url: String, +} + +impl AppsTestServer { + pub async fn mount(server: &MockServer) -> Result { + mount_oauth_metadata(server).await; + mount_streamable_http_json_rpc(server).await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } +} + +async fn mount_oauth_metadata(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/mcp")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), + "token_endpoint": format!("{}/oauth/token", server.uri()), + "scopes_supported": [""], + }))) + .mount(server) + .await; +} + +async fn mount_streamable_http_json_rpc(server: &MockServer) { + Mock::given(method("POST")) + .and(path_regex("^/api/codex/apps/?$")) + .respond_with(CodexAppsJsonRpcResponder) + .mount(server) + .await; +} + +struct CodexAppsJsonRpcResponder; + +impl Respond for CodexAppsJsonRpcResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = match serde_json::from_slice(&request.body) { + Ok(body) => body, + Err(error) => { + return ResponseTemplate::new(400).set_body_json(json!({ + "error": format!("invalid JSON-RPC body: {error}"), + })); + } + }; + + let Some(method) = body.get("method").and_then(Value::as_str) else { + return ResponseTemplate::new(400).set_body_json(json!({ + "error": "missing method in JSON-RPC request", + })); + }; + + match method { + "initialize" => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + let protocol_version = body + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .unwrap_or(PROTOCOL_VERSION); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": protocol_version, + "capabilities": { + "tools": { + "listChanged": true + } + }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION + } + } + })) + } + "notifications/initialized" => ResponseTemplate::new(202), + "tools/list" => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "tools": [ + { + "name": "calendar_create_event", + "description": "Create a calendar event.", + "inputSchema": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "starts_at": { "type": "string" }, + "timezone": { "type": "string" } + }, + "required": ["title", "starts_at"], + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": CONNECTOR_NAME + } + }, + { + "name": "calendar_list_events", + "description": "List calendar events.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer" } + }, + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": CONNECTOR_NAME + } + } + ], + "nextCursor": null + } + })) + } + method if method.starts_with("notifications/") => ResponseTemplate::new(202), + _ => { + let id = body.get("id").cloned().unwrap_or(Value::Null); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("method not found: {method}") + } + })) + } + } + } +} diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs new file mode 100644 index 0000000000..f9362ee30c --- /dev/null +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -0,0 +1,339 @@ +use serde_json::Value; + +use crate::responses::ResponsesRequest; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ContextSnapshotRenderMode { + #[default] + RedactedText, + FullText, + KindOnly, + KindWithTextPrefix { + max_chars: usize, + }, +} + +#[derive(Debug, Clone)] +pub struct ContextSnapshotOptions { + render_mode: ContextSnapshotRenderMode, +} + +impl Default for ContextSnapshotOptions { + fn default() -> Self { + Self { + render_mode: ContextSnapshotRenderMode::RedactedText, + } + } +} + +impl ContextSnapshotOptions { + pub fn render_mode(mut self, render_mode: ContextSnapshotRenderMode) -> Self { + self.render_mode = render_mode; + self + } +} + +pub fn format_request_input_snapshot( + request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let items = request.input(); + format_response_items_snapshot(items.as_slice(), options) +} + +pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshotOptions) -> String { + items + .iter() + .enumerate() + .map(|(idx, item)| { + let Some(item_type) = item.get("type").and_then(Value::as_str) else { + return format!("{idx:02}:"); + }; + + if options.render_mode == ContextSnapshotRenderMode::KindOnly { + return if item_type == "message" { + let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown"); + format!("{idx:02}:message/{role}") + } else { + format!("{idx:02}:{item_type}") + }; + } + + match item_type { + "message" => { + let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown"); + let text = item + .get("content") + .and_then(Value::as_array) + .map(|content| { + content + .iter() + .map(|entry| { + if let Some(text) = entry.get("text").and_then(Value::as_str) { + return format_snapshot_text(text, options); + } + let Some(content_type) = + entry.get("type").and_then(Value::as_str) + else { + return "".to_string(); + }; + let Some(content_object) = entry.as_object() else { + return format!("<{content_type}>"); + }; + let mut extra_keys = content_object + .keys() + .filter(|key| *key != "type" && *key != "text") + .cloned() + .collect::>(); + extra_keys.sort(); + if extra_keys.is_empty() { + format!("<{content_type}>") + } else { + format!("<{content_type}:{}>", extra_keys.join(",")) + } + }) + .collect::>() + .join(" | ") + }) + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| "".to_string()); + format!("{idx:02}:message/{role}:{text}") + } + "function_call" => { + let name = item.get("name").and_then(Value::as_str).unwrap_or("unknown"); + format!("{idx:02}:function_call/{name}") + } + "function_call_output" => { + let output = item + .get("output") + .and_then(Value::as_str) + .map(|output| format_snapshot_text(output, options)) + .unwrap_or_else(|| "".to_string()); + format!("{idx:02}:function_call_output:{output}") + } + "local_shell_call" => { + let command = item + .get("action") + .and_then(|action| action.get("command")) + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" ") + }) + .map(|command| format_snapshot_text(&command, options)) + .filter(|cmd| !cmd.is_empty()) + .unwrap_or_else(|| "".to_string()); + format!("{idx:02}:local_shell_call:{command}") + } + "reasoning" => { + let summary_text = item + .get("summary") + .and_then(Value::as_array) + .and_then(|summary| summary.first()) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + .map(|text| format_snapshot_text(text, options)) + .unwrap_or_else(|| "".to_string()); + let has_encrypted_content = item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()); + format!( + "{idx:02}:reasoning:summary={summary_text}:encrypted={has_encrypted_content}" + ) + } + "compaction" => { + let has_encrypted_content = item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()); + format!("{idx:02}:compaction:encrypted={has_encrypted_content}") + } + other => format!("{idx:02}:{other}"), + } + }) + .collect::>() + .join("\n") +} + +pub fn format_labeled_requests_snapshot( + scenario: &str, + sections: &[(&str, &ResponsesRequest)], + options: &ContextSnapshotOptions, +) -> String { + let sections = sections + .iter() + .map(|(title, request)| { + format!( + "## {title}\n{}", + format_request_input_snapshot(request, options) + ) + }) + .collect::>() + .join("\n\n"); + format!("Scenario: {scenario}\n\n{sections}") +} + +pub fn format_labeled_items_snapshot( + scenario: &str, + sections: &[(&str, &[Value])], + options: &ContextSnapshotOptions, +) -> String { + let sections = sections + .iter() + .map(|(title, items)| { + format!( + "## {title}\n{}", + format_response_items_snapshot(items, options) + ) + }) + .collect::>() + .join("\n\n"); + format!("Scenario: {scenario}\n\n{sections}") +} + +fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String { + match options.render_mode { + ContextSnapshotRenderMode::RedactedText => { + canonicalize_snapshot_text(text).replace('\n', "\\n") + } + ContextSnapshotRenderMode::FullText => text.replace('\n', "\\n"), + ContextSnapshotRenderMode::KindWithTextPrefix { max_chars } => { + let normalized = canonicalize_snapshot_text(text).replace('\n', "\\n"); + if normalized.chars().count() <= max_chars { + normalized + } else { + let prefix = normalized.chars().take(max_chars).collect::(); + format!("{prefix}...") + } + } + ContextSnapshotRenderMode::KindOnly => unreachable!(), + } +} + +fn canonicalize_snapshot_text(text: &str) -> String { + if text.starts_with("") { + return "".to_string(); + } + if text.starts_with("# AGENTS.md instructions for ") { + return "".to_string(); + } + if text.starts_with("") { + if let (Some(cwd_start), Some(cwd_end)) = (text.find(""), text.find("")) { + let cwd = &text[cwd_start + "".len()..cwd_end]; + return if cwd.ends_with("PRETURN_CONTEXT_DIFF_CWD") { + "".to_string() + } else { + ">".to_string() + }; + } + return "".to_string(); + } + if text.starts_with("You are performing a CONTEXT CHECKPOINT COMPACTION.") { + return "".to_string(); + } + if text.starts_with("Another language model started to solve this problem") + && let Some((_, summary)) = text.split_once('\n') + { + return format!("\n{summary}"); + } + text.to_string() +} + +#[cfg(test)] +mod tests { + use super::ContextSnapshotOptions; + use super::ContextSnapshotRenderMode; + use super::format_response_items_snapshot; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn full_text_mode_preserves_unredacted_text() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions for /tmp/example" + }] + })]; + + let rendered = format_response_items_snapshot( + &items, + &ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::FullText), + ); + + assert_eq!( + rendered, + "00:message/user:# AGENTS.md instructions for /tmp/example" + ); + } + + #[test] + fn redacted_text_mode_keeps_canonical_placeholders() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions for /tmp/example" + }] + })]; + + let rendered = format_response_items_snapshot( + &items, + &ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::RedactedText), + ); + + assert_eq!(rendered, "00:message/user:"); + } + + #[test] + fn image_only_message_is_rendered_as_non_text_span() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_image", + "image_url": "data:image/png;base64,AAAA" + }] + })]; + + let rendered = format_response_items_snapshot(&items, &ContextSnapshotOptions::default()); + + assert_eq!(rendered, "00:message/user:"); + } + + #[test] + fn mixed_text_and_image_message_keeps_image_span() { + let items = vec![json!({ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "" + }, + { + "type": "input_image", + "image_url": "data:image/png;base64,AAAA" + }, + { + "type": "input_text", + "text": "" + } + ] + })]; + + let rendered = format_response_items_snapshot(&items, &ContextSnapshotOptions::default()); + + assert_eq!( + rendered, + "00:message/user: | | " + ); + } +} diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 87a8e1adb2..a7fda23cd0 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -12,6 +12,8 @@ use codex_utils_absolute_path::AbsolutePathBuf; use regex_lite::Regex; use std::path::PathBuf; +pub mod apps_test_server; +pub mod context_snapshot; pub mod process; pub mod responses; pub mod streaming_sse; @@ -24,6 +26,26 @@ fn enable_deterministic_unified_exec_process_ids_for_tests() { codex_core::test_support::set_deterministic_process_ids(true); } +#[ctor] +fn configure_insta_workspace_root_for_snapshot_tests() { + if std::env::var_os("INSTA_WORKSPACE_ROOT").is_some() { + return; + } + + let workspace_root = codex_utils_cargo_bin::repo_root() + .ok() + .map(|root| root.join("codex-rs")); + + if let Some(workspace_root) = workspace_root + && let Ok(workspace_root) = workspace_root.canonicalize() + { + // Safety: this ctor runs at process startup before test threads begin. + unsafe { + std::env::set_var("INSTA_WORKSPACE_ROOT", workspace_root); + } + } +} + #[track_caller] pub fn assert_regex_match<'s>(pattern: &str, actual: &'s str) -> regex_lite::Captures<'s> { let regex = Regex::new(pattern).unwrap_or_else(|err| { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 9faca23888..c434c89717 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -5,6 +5,8 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; use futures::SinkExt; use futures::StreamExt; @@ -112,6 +114,14 @@ impl ResponsesRequest { self.0.body.clone() } + pub fn body_contains_text(&self, text: &str) -> bool { + let json_fragment = serde_json::to_string(text) + .expect("serialize text to JSON") + .trim_matches('"') + .to_string(); + self.body_json().to_string().contains(&json_fragment) + } + pub fn instructions_text(&self) -> String { self.body_json()["instructions"] .as_str() @@ -131,6 +141,22 @@ impl ResponsesRequest { .collect() } + /// Returns all `input_image` `image_url` spans from `message` inputs for the provided role. + pub fn message_input_image_urls(&self, role: &str) -> Vec { + self.inputs_of_type("message") + .into_iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) + .filter_map(|item| item.get("content").and_then(Value::as_array).cloned()) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) + .filter_map(|span| { + span.get("image_url") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect() + } + pub fn input(&self) -> Vec { self.body_json()["input"] .as_array() @@ -480,6 +506,18 @@ pub fn ev_assistant_message(id: &str, text: &str) -> Value { }) } +pub fn user_message_item(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + pub fn ev_message_item_added(id: &str, text: &str) -> Value { serde_json::json!({ "type": "response.output_item.added", @@ -818,15 +856,24 @@ where } pub async fn mount_compact_json_once(server: &MockServer, body: serde_json::Value) -> ResponseMock { - let (mock, response_mock) = compact_mock(); - mock.respond_with( + mount_compact_response_once( + server, ResponseTemplate::new(200) .insert_header("content-type", "application/json") - .set_body_json(body.clone()), + .set_body_json(body), ) - .up_to_n_times(1) - .mount(server) - .await; + .await +} + +pub async fn mount_compact_response_once( + server: &MockServer, + response: ResponseTemplate, +) -> ResponseMock { + let (mock, response_mock) = compact_mock(); + mock.respond_with(response) + .up_to_n_times(1) + .mount(server) + .await; response_mock } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 72de76bab8..09d6755d3d 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1479,6 +1479,44 @@ fn scenarios() -> Vec { output_contains: "rejected by user", }, }, + ScenarioSpec { + name: "safe command with heredoc and redirect still requires approval", + approval_policy: AskForApproval::OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::RunUnifiedExecCommand { + command: "cat <<'EOF' > /tmp/out.txt \nhello\nEOF", + justification: None, + }, + sandbox_permissions: SandboxPermissions::RequireEscalated, + features: vec![Feature::UnifiedExec], + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, + ScenarioSpec { + name: "compound command with one safe command still requires approval", + approval_policy: AskForApproval::OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::RunUnifiedExecCommand { + command: "cat ./one.txt && touch ./two.txt", + justification: None, + }, + sandbox_permissions: SandboxPermissions::RequireEscalated, + features: vec![Feature::UnifiedExec], + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, ] } @@ -1890,14 +1928,15 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts Ok(()) } +// todo(dylan) add ScenarioSpec support for rules #[tokio::test(flavor = "current_thread")] #[cfg(unix)] -async fn heredoc_with_chained_allowed_prefix_still_requires_approval() -> Result<()> { +async fn compound_command_with_one_safe_command_still_requires_approval() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1913,12 +1952,12 @@ async fn heredoc_with_chained_allowed_prefix_still_requires_approval() -> Result )?; let call_id = "heredoc-with-chained-prefix"; - let command = "cat <<'EOF' > /tmp/test.txt && touch allow-prefix.txt\nhello\nEOF"; + let command = "touch ./test.txt && rm ./test.txt"; let (event, expected_command) = ActionKind::RunCommand { command } .prepare(&test, &server, call_id, SandboxPermissions::UseDefault) .await?; let expected_command = - expected_command.expect("heredoc chained command scenario should produce a shell command"); + expected_command.expect("compound command should produce a shell command"); let _ = mount_sse_once( &server, @@ -1940,7 +1979,7 @@ async fn heredoc_with_chained_allowed_prefix_still_requires_approval() -> Result submit_turn( &test, - "heredoc chained prefix", + "compound command", approval_policy, sandbox_policy.clone(), ) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7c6799df92..bbdbc73233 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -34,6 +34,7 @@ use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; +use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; @@ -671,6 +672,10 @@ async fn includes_user_instructions_message_in_request() { async fn includes_apps_guidance_as_developer_message_when_enabled() { skip_if_no_network!(); let server = MockServer::start().await; + let apps_server = AppsTestServer::mount(&server) + .await + .expect("mount apps MCP mock"); + let apps_base_url = apps_server.chatgpt_base_url.clone(); let resp_mock = mount_sse_once( &server, @@ -680,8 +685,10 @@ async fn includes_apps_guidance_as_developer_message_when_enabled() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(|config| { + .with_config(move |config| { config.features.enable(Feature::Apps); + config.features.disable(Feature::AppsMcpGateway); + config.chatgpt_base_url = apps_base_url; }); let codex = builder .build(&server) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 914673bdb3..22a6c72076 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5,7 +5,6 @@ use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; -use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::ItemCompletedEvent; @@ -20,6 +19,9 @@ use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::user_input::UserInput; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::context_snapshot::ContextSnapshotRenderMode; use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; @@ -27,7 +29,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; -use std::collections::VecDeque; +use std::path::PathBuf; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -60,10 +62,11 @@ const SECOND_AUTO_SUMMARY: &str = "SECOND_AUTO_SUMMARY"; const FINAL_REPLY: &str = "FINAL_REPLY"; const CONTEXT_LIMIT_MESSAGE: &str = "Your input exceeds the context window of this model. Please adjust your input and try again."; -const DUMMY_FUNCTION_NAME: &str = "unsupported_tool"; +const DUMMY_FUNCTION_NAME: &str = "test_tool"; const DUMMY_CALL_ID: &str = "call-multi-auto"; const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; const POST_AUTO_USER_MSG: &str = "post auto follow-up"; +const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; @@ -75,23 +78,6 @@ fn summary_with_prefix(summary: &str) -> String { format!("{SUMMARY_PREFIX}\n{summary}") } -fn drop_call_id(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - obj.retain(|k, _| k != "call_id"); - for v in obj.values_mut() { - drop_call_id(v); - } - } - serde_json::Value::Array(arr) => { - for v in arr { - drop_call_id(v); - } - } - _ => {} - } -} - fn set_test_compact_prompt(config: &mut Config) { config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); } @@ -142,6 +128,15 @@ fn assert_pre_sampling_switch_compaction_requests( body_contains_text(&compact_body, SUMMARIZATION_PROMPT), "pre-sampling compact request should include summarization prompt" ); + assert!( + !compact_body.contains(""), + "pre-sampling compact request should strip trailing model-switch update item" + ); + let follow_up_body = follow_up.to_string(); + assert!( + follow_up_body.contains(""), + "follow-up request after successful model-switch compaction should include model-switch update item" + ); } async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc) { @@ -185,6 +180,21 @@ async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc ContextSnapshotOptions { + ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }) +} + +fn format_labeled_requests_snapshot( + scenario: &str, + sections: &[(&str, &core_test_support::responses::ResponsesRequest)], +) -> String { + context_snapshot::format_labeled_requests_snapshot( + scenario, + sections, + &context_snapshot_options(), + ) +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { @@ -399,8 +409,15 @@ async fn manual_compact_uses_custom_prompt() { skip_if_no_network!(); let server = start_mock_server().await; - let sse_stream = sse(vec![ev_completed("r1")]); - let response_mock = mount_sse_once(&server, sse_stream).await; + let first_turn = sse(vec![ + ev_assistant_message("m0", FIRST_REPLY), + ev_completed_with_tokens("r0", 80), + ]); + let compact_turn = sse(vec![ + ev_assistant_message("m1", SUMMARY_TEXT), + ev_completed_with_tokens("r1", 100), + ]); + let request_log = mount_sse_sequence(&server, vec![first_turn, compact_turn]).await; let custom_prompt = "Use this compact prompt instead"; @@ -415,6 +432,18 @@ async fn manual_compact_uses_custom_prompt() { .expect("create conversation") .codex; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit first user turn"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + codex.submit(Op::Compact).await.expect("trigger compact"); let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; let EventMsg::Warning(WarningEvent { message }) = warning_event else { @@ -423,7 +452,13 @@ async fn manual_compact_uses_custom_prompt() { assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let body = response_mock.single_request().body_json(); + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 2, + "expected first turn and compact requests" + ); + let body = requests[1].body_json(); let input = body .get("input") @@ -1672,7 +1707,6 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); }); let test = builder.build(&server).await.expect("build test codex"); @@ -1733,6 +1767,21 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { previous_model, next_model, ); + + insta::assert_snapshot!( + "pre_sampling_model_switch_compaction_shapes", + format_labeled_requests_snapshot( + "Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.", + &[ + ("Initial Request (Previous Model)", &requests[0]), + ("Pre-sampling Compaction Request", &requests[1]), + ( + "Post-Compaction Follow-up Request (Next Model)", + &requests[2] + ), + ] + ) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1780,7 +1829,6 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); }); let initial = initial_builder .build(&server) @@ -1834,7 +1882,6 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); }); let resumed = resumed_builder .resume(&server, home, rollout_path) @@ -2124,6 +2171,82 @@ async fn manual_compact_retries_after_context_window_error() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. +// Current main behavior around non-context manual /compact failures is known-incorrect. +#[ignore = "behavior change covered in follow-up compaction PR"] +async fn manual_compact_non_context_failure_retries_then_emits_task_error() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let user_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed("r1"), + ]); + let compact_failed_1 = sse_failed( + "resp-fail-1", + "server_error", + "temporary compact failure one", + ); + let compact_failed_2 = sse_failed( + "resp-fail-2", + "server_error", + "temporary compact failure two", + ); + + mount_sse_sequence(&server, vec![user_turn, compact_failed_1, compact_failed_2]).await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(1); + + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200_000); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first turn".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex.submit(Op::Compact).await.expect("trigger compact"); + + let reconnect_message = wait_for_event_match(&codex, |event| match event { + EventMsg::StreamError(stream_error) => Some(stream_error.message.clone()), + _ => None, + }) + .await; + assert!( + reconnect_message.contains("Reconnecting... 1/1"), + "expected reconnect stream error message, got {reconnect_message}" + ); + + let task_error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + assert!( + task_error_message.contains("Error running local compact task"), + "expected local compact task error prefix, got {task_error_message}" + ); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn manual_compact_twice_preserves_latest_user_messages() { skip_if_no_network!(); @@ -2228,114 +2351,89 @@ async fn manual_compact_twice_preserves_latest_user_messages() { 5, "expected exactly 5 requests (user turn, compact, user turn, compact, final turn)" ); - let contains_user_text = |input: &[serde_json::Value], expected: &str| -> bool { - input.iter().any(|item| { - item.get("type").and_then(|v| v.as_str()) == Some("message") - && item.get("role").and_then(|v| v.as_str()) == Some("user") - && item - .get("content") - .and_then(|v| v.as_array()) - .is_some_and(|arr| { - arr.iter().any(|entry| { - entry.get("text").and_then(|v| v.as_str()) == Some(expected) - }) - }) - }) + let contains_user_text = |request: &core_test_support::responses::ResponsesRequest, + expected: &str| { + request + .message_input_texts("user") + .iter() + .any(|text| text == expected) }; - let first_turn_input = requests[0].input(); assert!( - contains_user_text(&first_turn_input, first_user_message), + contains_user_text(&requests[0], first_user_message), "first turn request missing first user message" ); assert!( - !contains_user_text(&first_turn_input, SUMMARIZATION_PROMPT), + !contains_user_text(&requests[0], SUMMARIZATION_PROMPT), "first turn request should not include summarization prompt" ); - let first_compact_input = requests[1].input(); assert!( - contains_user_text(&first_compact_input, first_user_message), + contains_user_text(&requests[1], first_user_message), "first compact request should include history before compaction" ); - let second_turn_input = requests[2].input(); assert!( - contains_user_text(&second_turn_input, second_user_message), + contains_user_text(&requests[2], second_user_message), "second turn request missing second user message" ); assert!( - contains_user_text(&second_turn_input, first_user_message), + contains_user_text(&requests[2], first_user_message), "second turn request should include the compacted user history" ); - let second_compact_input = requests[3].input(); assert!( - contains_user_text(&second_compact_input, second_user_message), + contains_user_text(&requests[3], second_user_message), "second compact request should include latest history" ); - let first_compact_has_prompt = contains_user_text(&first_compact_input, SUMMARIZATION_PROMPT); - let second_compact_has_prompt = contains_user_text(&second_compact_input, SUMMARIZATION_PROMPT); + insta::assert_snapshot!( + "manual_compact_with_history_shapes", + format_labeled_requests_snapshot( + "Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message.", + &[ + ("Local Compaction Request", &requests[1]), + ("Local Post-Compaction History Layout", &requests[2]), + ] + ) + ); + + let first_compact_has_prompt = contains_user_text(&requests[1], SUMMARIZATION_PROMPT); + let second_compact_has_prompt = contains_user_text(&requests[3], SUMMARIZATION_PROMPT); assert_eq!( first_compact_has_prompt, second_compact_has_prompt, "compact requests should consistently include or omit the summarization prompt" ); - let mut final_output = requests + let first_request_user_texts = requests[0].message_input_texts("user"); + let first_turn_user_index = first_request_user_texts + .len() + .checked_sub(1) + .unwrap_or_else(|| panic!("first turn request missing user messages")); + assert_eq!( + first_request_user_texts[first_turn_user_index], first_user_message, + "first turn request should end with the submitted user message" + ); + let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; + + let final_request_user_texts = requests .last() .unwrap_or_else(|| panic!("final turn request missing for {final_user_message}")) - .input() - .into_iter() - .collect::>(); - - // Permissions developer message - final_output.pop_front(); - // User instructions (project docs/skills) - final_output.pop_front(); - // Environment context - final_output.pop_front(); - - let _ = final_output - .iter_mut() - .map(drop_call_id) - .collect::>(); - + .message_input_texts("user"); + assert!( + final_request_user_texts + .as_slice() + .starts_with(seeded_user_prefix), + "final request should start with seeded user prefix from first request: {seeded_user_prefix:?}" + ); + let final_output = &final_request_user_texts[seeded_user_prefix.len()..]; let expected = vec![ - json!({ - "content": vec![json!({ - "text": first_user_message, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), - json!({ - "content": vec![json!({ - "text": second_user_message, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), - json!({ - "content": vec![json!({ - "text": expected_second_summary, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), - json!({ - "content": vec![json!({ - "text": final_user_message, - "type": "input_text", - })], - "role": "user", - "type": "message", - }), + first_user_message.to_string(), + second_user_message.to_string(), + expected_second_summary, + final_user_message.to_string(), ]; - assert_eq!(final_output, expected); + assert_eq!(final_output, expected.as_slice()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2450,7 +2548,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { +async fn snapshot_request_shape_mid_turn_continuation_compaction() { skip_if_no_network!(); let server = start_mock_server().await; @@ -2458,29 +2556,25 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let context_window = 100; let limit = context_window * 90 / 100; let over_limit_tokens = context_window * 95 / 100 + 1; - let follow_up_user = "FOLLOW_UP_AFTER_LIMIT"; let first_turn = sse(vec![ ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), - ev_completed_with_tokens("r1", 50), - ]); - let function_call_follow_up = sse(vec![ - ev_assistant_message("m2", FINAL_REPLY), - ev_completed_with_tokens("r2", over_limit_tokens), + ev_completed_with_tokens("r1", over_limit_tokens), ]); let auto_summary_payload = auto_summary(AUTO_SUMMARY_TEXT); let auto_compact_turn = sse(vec![ - ev_assistant_message("m3", &auto_summary_payload), + ev_assistant_message("m2", &auto_summary_payload), ev_completed_with_tokens("r3", 10), ]); - let post_auto_compact_turn = sse(vec![ev_completed_with_tokens("r4", 10)]); + let post_auto_compact_turn = sse(vec![ + ev_assistant_message("m3", FINAL_REPLY), + ev_completed_with_tokens("r4", 10), + ]); // Mount responses in order and keep mocks only for the ones we assert on. let first_turn_mock = mount_sse_once(&server, first_turn).await; - let follow_up_mock = mount_sse_once(&server, function_call_follow_up).await; let auto_compact_mock = mount_sse_once(&server, auto_compact_turn).await; - // We don't assert on the post-compact request, so no need to keep its mock. - mount_sse_once(&server, post_auto_compact_turn).await; + let post_auto_compact_mock = mount_sse_once(&server, post_auto_compact_turn).await; let model_provider = non_openai_model_provider(&server); @@ -2505,19 +2599,6 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { wait_for_event(&codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; - codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: follow_up_user.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }) - .await - .unwrap(); - - wait_for_event(&codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; - // Assert first request captured expected user message that triggers function call. let first_request = first_turn_mock.single_request().input(); assert!( @@ -2534,7 +2615,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { "first request should include the user message that triggers the function call" ); - let function_call_output = follow_up_mock + let function_call_output = auto_compact_mock .single_request() .function_call_output(DUMMY_CALL_ID); let output_text = function_call_output @@ -2549,7 +2630,24 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); assert!( body_contains_text(&auto_compact_body, SUMMARIZATION_PROMPT), - "auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" + "mid-turn auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" + ); + + insta::assert_snapshot!( + "mid_turn_compaction_shapes", + format_labeled_requests_snapshot( + "True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.", + &[ + ( + "Local Compaction Request", + &auto_compact_mock.single_request() + ), + ( + "Local Post-Compaction History Layout", + &post_auto_compact_mock.single_request() + ), + ] + ) ); } @@ -2819,3 +2917,270 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { "remote compaction should run once after the reasoning header clears" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once pre-turn compaction includes incoming user input. +async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_message() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let sse1 = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 60), + ]); + let sse2 = sse(vec![ + ev_assistant_message("m2", "SECOND_REPLY"), + ev_completed_with_tokens("r2", 500), + ]); + let sse3 = sse(vec![ + ev_assistant_message("m3", "PRE_TURN_SUMMARY"), + ev_completed_with_tokens("r3", 100), + ]); + let sse4 = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 80), + ]); + let request_log = mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await; + + let model_provider = non_openai_model_provider(&server); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + for user in ["USER_ONE", "USER_TWO"] { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + } + codex + .submit(Op::OverrideTurnContext { + cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await + .expect("override turn context"); + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + .to_string(); + codex + .submit(Op::UserInput { + items: vec![ + UserInput::Image { + image_url: image_url.clone(), + }, + UserInput::Text { + text: "USER_THREE".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 4, "expected user, user, compact, follow-up"); + + insta::assert_snapshot!( + "pre_turn_compaction_including_incoming_shapes", + format_labeled_requests_snapshot( + "Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.", + &[ + ("Local Compaction Request", &requests[2]), + ("Local Post-Compaction History Layout", &requests[3]), + ] + ) + ); + let compact_request_user_texts = requests[2].message_input_texts("user"); + assert!( + !compact_request_user_texts + .iter() + .any(|text| text == "USER_THREE"), + "current behavior excludes incoming user message from pre-turn compaction input" + ); + let follow_up_user_texts = requests[3].message_input_texts("user"); + assert!( + follow_up_user_texts.iter().any(|text| text == "USER_THREE"), + "expected post-compaction follow-up request to keep incoming user text" + ); + let follow_up_user_images = requests[3].message_input_image_urls("user"); + assert!( + follow_up_user_images + .iter() + .any(|url| url == image_url.as_str()), + "expected post-compaction follow-up request to keep incoming user image content" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once pre-turn compaction context-overflow handling includes incoming +// user input and emits richer oversized-input messaging. +async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 500), + ]); + let mut responses = vec![first_turn]; + responses.extend( + (0..6).map(|_| { + sse_failed( + "compact-failed", + "context_length_exceeded", + "Your input exceeds the context window of this model. Please adjust your input and try again.", + ) + }), + ); + let request_log = mount_sse_sequence(&server, responses).await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(0); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit first user"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit second user"); + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert!( + requests.len() >= 2, + "expected first turn and at least one compaction request" + ); + + insta::assert_snapshot!( + "pre_turn_compaction_context_window_exceeded_shapes", + format_labeled_requests_snapshot( + "Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.", + &[( + "Local Compaction Request (Incoming User Excluded)", + &requests[1] + ),] + ) + ); + + assert!( + error_message.contains("ran out of room in the model's context window"), + "expected context window exceeded message, got {error_message}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let compact_turn = sse(vec![ + ev_assistant_message("m1", "MANUAL_EMPTY_SUMMARY"), + ev_completed_with_tokens("r1", 90), + ]); + let follow_up_turn = sse(vec![ + ev_assistant_message("m2", FINAL_REPLY), + ev_completed_with_tokens("r2", 80), + ]); + let request_log = mount_sse_sequence(&server, vec![compact_turn, follow_up_turn]).await; + + let model_provider = non_openai_model_provider(&server); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex.submit(Op::Compact).await.expect("run /compact"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit follow-up user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 2, + "expected manual /compact request and follow-up turn request" + ); + + insta::assert_snapshot!( + "manual_compact_without_prev_user_shapes", + format_labeled_requests_snapshot( + "Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.", + &[ + ("Local Compaction Request", &requests[0]), + ("Local Post-Compaction History Layout", &requests[1]), + ] + ) + ); +} diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 3311427d4b..6c31b8dbeb 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,9 +1,11 @@ #![allow(clippy::expect_used)] use std::fs; +use std::path::PathBuf; use anyhow::Result; use codex_core::CodexAuth; +use codex_core::compact::SUMMARY_PREFIX; use codex_core::protocol::EventMsg; use codex_core::protocol::ItemCompletedEvent; use codex_core::protocol::ItemStartedEvent; @@ -14,6 +16,9 @@ use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::context_snapshot::ContextSnapshotRenderMode; use core_test_support::responses; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; @@ -23,6 +28,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use wiremock::ResponseTemplate; fn approx_token_count(text: &str) -> i64 { i64::try_from(text.len().saturating_add(3) / 4).unwrap_or(i64::MAX) @@ -39,6 +45,29 @@ fn estimate_compact_payload_tokens(request: &responses::ResponsesRequest) -> i64 .saturating_add(approx_token_count(&request.instructions_text())) } +const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; +const DUMMY_FUNCTION_NAME: &str = "test_tool"; + +fn summary_with_prefix(summary: &str) -> String { + format!("{SUMMARY_PREFIX}\n{summary}") +} + +fn context_snapshot_options() -> ContextSnapshotOptions { + ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }) +} + +fn format_labeled_requests_snapshot( + scenario: &str, + sections: &[(&str, &responses::ResponsesRequest)], +) -> String { + context_snapshot::format_labeled_requests_snapshot( + scenario, + sections, + &context_snapshot_options(), + ) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_replaces_history_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); @@ -65,15 +94,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -134,12 +155,9 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { "expected compact request to include assistant history" ); - let follow_up_body = responses_mock - .requests() - .last() - .expect("follow-up request missing") - .body_json() - .to_string(); + let response_requests = responses_mock.requests(); + let follow_up_request = response_requests.last().expect("follow-up request missing"); + let follow_up_body = follow_up_request.body_json().to_string(); assert!( follow_up_body.contains("REMOTE_COMPACTED_SUMMARY"), "expected follow-up request to use compacted history" @@ -152,6 +170,21 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { !follow_up_body.contains("FIRST_REMOTE_REPLY"), "expected follow-up request to drop pre-compaction assistant messages" ); + assert!( + !follow_up_body.contains("hello remote compact"), + "expected follow-up request to drop compacted-away user turns when remote output omits them" + ); + + insta::assert_snapshot!( + "remote_manual_compact_with_history_shapes", + format_labeled_requests_snapshot( + "Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", follow_up_request), + ] + ) + ); Ok(()) } @@ -184,15 +217,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -221,7 +246,8 @@ async fn remote_compact_runs_automatically() -> Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; assert!(message); assert_eq!(compact_mock.requests().len(), 1); - let follow_up_body = responses_mock.single_request().body_json().to_string(); + let follow_up_request = responses_mock.single_request(); + let follow_up_body = follow_up_request.body_json().to_string(); assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY")); assert!(follow_up_body.contains("ENCRYPTED_COMPACTION_SUMMARY")); @@ -497,7 +523,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { ) .await; - let compact_mock = responses::mount_compact_json_once( + let first_compact_mock = responses::mount_compact_json_once( harness.server(), serde_json::json!({ "output": "invalid compact payload shape" }), ) @@ -541,18 +567,29 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { assert!( error_message.contains("Error running remote compact task"), - "expected compact failure error, got {error_message}" + "expected remote compact task error prefix, got {error_message}" ); assert_eq!( - compact_mock.requests().len(), + first_compact_mock.requests().len(), 1, - "expected exactly one remote compact attempt" + "expected first remote compact attempt with incoming items" ); assert!( post_compact_turn_mock.requests().is_empty(), "expected agent loop to stop after compaction failure" ); + insta::assert_snapshot!( + "remote_pre_turn_compaction_failure_shapes", + format_labeled_requests_snapshot( + "Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.", + &[( + "Remote Compaction Request (Incoming User Excluded)", + &first_compact_mock.single_request() + ),] + ) + ); + Ok(()) } @@ -778,15 +815,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -851,6 +880,68 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await?; + let codex = harness.test().codex.clone(); + + mount_sse_once( + harness.server(), + sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_REPLY"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let compact_mock = responses::mount_compact_json_once( + harness.server(), + serde_json::json!({ "output": "invalid compact payload shape" }), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "manual remote compact".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + codex.submit(Op::Compact).await?; + + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + assert!( + error_message.contains("Error running remote compact task"), + "expected remote compact task error prefix, got {error_message}" + ); + assert!( + error_message.contains("invalid compact payload shape") + || error_message.contains("invalid type: string"), + "expected invalid compact payload details, got {error_message}" + ); + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. +// Current main behavior for rollout replacement-history persistence is known-incorrect. +#[ignore = "behavior change covered in follow-up compaction PR"] async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> { skip_if_no_network!(Ok(())); @@ -876,15 +967,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "COMPACTED_USER_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("COMPACTED_USER_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -980,11 +1063,11 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) }); - if has_compacted_user_summary - && has_compaction_item - && has_compacted_assistant_note - && has_permissions_developer_message - { + if has_compacted_user_summary && has_compaction_item && has_compacted_assistant_note { + assert!( + !has_permissions_developer_message, + "manual remote compact rollout replacement history should not inject permissions context" + ); saw_compacted_history = true; break; } @@ -1036,15 +1119,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1177,15 +1252,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() .await; let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "REMOTE_COMPACTED_SUMMARY".to_string(), - }], - end_turn: None, - phase: None, - }, + responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1250,3 +1317,344 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once remote pre-turn compaction includes incoming user input. +async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_user_message() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", 60), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m2", "REMOTE_SECOND_REPLY"), + responses::ev_completed_with_tokens("r2", 500), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m3", "REMOTE_FINAL_REPLY"), + responses::ev_completed_with_tokens("r3", 80), + ]), + ], + ) + .await; + + let compacted_history = vec![ + responses::user_message_item("USER_ONE"), + responses::user_message_item("USER_TWO"), + responses::user_message_item(&summary_with_prefix("REMOTE_PRE_TURN_SUMMARY")), + ]; + let compact_mock = responses::mount_compact_json_once( + harness.server(), + serde_json::json!({ "output": compacted_history }), + ) + .await; + + for user in ["USER_ONE", "USER_TWO", "USER_THREE"] { + if user == "USER_THREE" { + codex + .submit(Op::OverrideTurnContext { + cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + } + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + } + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected user, user, and post-compact turn" + ); + + let compact_request = compact_mock.single_request(); + insta::assert_snapshot!( + "remote_pre_turn_compaction_including_incoming_shapes", + format_labeled_requests_snapshot( + "Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", &requests[2]), + ] + ) + ); + assert_eq!( + requests[2] + .message_input_texts("user") + .iter() + .filter(|text| text.as_str() == "USER_THREE") + .count(), + 1, + "post-compaction request should contain incoming user exactly once from runtime append" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once remote pre-turn compaction context-overflow handling includes +// incoming user input and emits richer oversized-input messaging. +async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceeded() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![responses::sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", 500), + ])], + ) + .await; + + let compact_mock = responses::mount_compact_response_once( + harness.server(), + ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "code": "context_length_exceeded", + "message": "Your input exceeds the context window of this model. Please adjust your input and try again." + } + })), + ) + .await; + let post_compact_turn_mock = responses::mount_sse_once( + harness.server(), + responses::sse(vec![ + responses::ev_assistant_message("m2", "REMOTE_POST_COMPACT_SHOULD_NOT_RUN"), + responses::ev_completed_with_tokens("r2", 80), + ]), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 1, + "expected no post-compaction follow-up turn request after compact failure" + ); + assert!( + post_compact_turn_mock.requests().is_empty(), + "expected turn to stop after compaction failure" + ); + + let include_attempt_request = compact_mock.single_request(); + insta::assert_snapshot!( + "remote_pre_turn_compaction_context_window_exceeded_shapes", + format_labeled_requests_snapshot( + "Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.", + &[( + "Remote Compaction Request (Incoming User Excluded)", + &include_attempt_request + ),] + ) + ); + assert!( + error_message.to_lowercase().contains("context window"), + "expected context window failure to surface, got {error_message}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_function_call("call-remote-mid-turn", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens("r1", 500), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m2", "REMOTE_MID_TURN_FINAL_REPLY"), + responses::ev_completed_with_tokens("r2", 80), + ]), + ], + ) + .await; + + let compacted_history = vec![ + responses::user_message_item("USER_ONE"), + responses::user_message_item(&summary_with_prefix("REMOTE_MID_TURN_SUMMARY")), + ]; + let compact_mock = responses::mount_compact_json_once( + harness.server(), + serde_json::json!({ "output": compacted_history }), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected initial and post-compact requests" + ); + + let compact_request = compact_mock.single_request(); + insta::assert_snapshot!( + "remote_mid_turn_compaction_shapes", + format_labeled_requests_snapshot( + "Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", &requests[1]), + ] + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// TODO(ccunningham): Update once manual remote /compact with no prior user turn becomes a no-op. +async fn snapshot_request_shape_remote_manual_compact_without_previous_user_messages() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_once( + harness.server(), + responses::sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_MANUAL_EMPTY_FOLLOW_UP_REPLY"), + responses::ev_completed_with_tokens("r1", 80), + ]), + ) + .await; + + let compact_mock = + responses::mount_compact_json_once(harness.server(), serde_json::json!({ "output": [] })) + .await; + + codex.submit(Op::Compact).await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!( + compact_mock.requests().len(), + 1, + "current behavior still issues remote compaction for manual /compact without prior user" + ); + let compact_request = compact_mock.single_request(); + let follow_up_request = responses_mock.single_request(); + insta::assert_snapshot!( + "remote_manual_compact_without_prev_user_shapes", + format_labeled_requests_snapshot( + "Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.", + &[ + ("Remote Compaction Request", &compact_request), + ("Remote Post-Compaction History Layout", &follow_up_request), + ] + ) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index a8323fcd71..0e4ffd88b7 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -113,75 +113,43 @@ async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn emits_deprecation_notice_for_web_search_feature_flags() -> anyhow::Result<()> { +async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - let server = start_mock_server().await; + for enabled in [true, false] { + let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - let mut entries = BTreeMap::new(); - entries.insert("web_search_request".to_string(), true); - config.features.apply_map(&entries); - }); + let mut builder = test_codex().with_config(move |config| { + let mut entries = BTreeMap::new(); + entries.insert("web_search_request".to_string(), enabled); + config.features.apply_map(&entries); + }); - let TestCodex { codex, .. } = builder.build(&server).await?; + let TestCodex { codex, .. } = builder.build(&server).await?; - let notice = wait_for_event_match(&codex, |event| match event { - EventMsg::DeprecationNotice(ev) if ev.summary.contains("[features].web_search_request") => { - Some(ev.clone()) - } - _ => None, - }) - .await; + let notice = wait_for_event_match(&codex, |event| match event { + EventMsg::DeprecationNotice(ev) + if ev.summary.contains("[features].web_search_request") => + { + Some(ev.clone()) + } + _ => None, + }) + .await; - let DeprecationNoticeEvent { summary, details } = notice; - assert_eq!( - summary, - "`[features].web_search_request` is deprecated. Use `web_search` instead.".to_string(), - ); - assert_eq!( - details.as_deref(), - Some( - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." - ), - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn emits_deprecation_notice_for_disabled_web_search_feature_flag() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - - let mut builder = test_codex().with_config(|config| { - let mut entries = BTreeMap::new(); - entries.insert("web_search_request".to_string(), false); - config.features.apply_map(&entries); - }); - - let TestCodex { codex, .. } = builder.build(&server).await?; - - let notice = wait_for_event_match(&codex, |event| match event { - EventMsg::DeprecationNotice(ev) if ev.summary.contains("[features].web_search_request") => { - Some(ev.clone()) - } - _ => None, - }) - .await; - - let DeprecationNoticeEvent { summary, details } = notice; - assert_eq!( - summary, - "`[features].web_search_request` is deprecated. Use `web_search` instead.".to_string(), - ); - assert_eq!( - details.as_deref(), - Some( - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." - ), - ); + let DeprecationNoticeEvent { summary, details } = notice; + assert_eq!( + summary, + "`[features].web_search_request` is deprecated because web search is enabled by default." + .to_string(), + ); + assert_eq!( + details.as_deref(), + Some( + "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." + ), + ); + } Ok(()) } diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index c3cb15875b..c857affd2b 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -37,6 +37,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Result< supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), @@ -266,13 +266,12 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| { - config.features.enable(Feature::RemoteModels); config.model = Some(image_model_slug.to_string()); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let _ = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" .to_string(); @@ -324,32 +323,14 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< assert_eq!(requests.len(), 2, "expected two model requests"); let first_request = requests.first().expect("expected first request"); - let first_has_input_image = first_request.inputs_of_type("message").iter().any(|item| { - item.get("content") - .and_then(Value::as_array) - .is_some_and(|content| { - content - .iter() - .any(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) - }) - }); assert!( - first_has_input_image, + !first_request.message_input_image_urls("user").is_empty(), "first request should include the uploaded image" ); let second_request = requests.last().expect("expected second request"); - let second_has_input_image = second_request.inputs_of_type("message").iter().any(|item| { - item.get("content") - .and_then(Value::as_array) - .is_some_and(|content| { - content - .iter() - .any(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) - }) - }); assert!( - !second_has_input_image, + second_request.message_input_image_urls("user").is_empty(), "second request should strip unsupported image content" ); let second_user_texts = second_request.message_input_texts("user"); @@ -404,6 +385,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), @@ -451,15 +433,12 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some(large_model_slug.to_string()); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); - let available_models = models_manager - .list_models(&test.config, RefreshStrategy::Online) - .await; + let available_models = models_manager.list_models(RefreshStrategy::Online).await; assert!( available_models .iter() diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 676b34eb70..026be98dab 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -36,7 +36,6 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { .with_model(model) // Keep tool expectations stable when the default web_search mode changes. .with_config(|config| { - config.features.enable(Feature::RemoteModels); config .web_search_mode .set(WebSearchMode::Cached) @@ -75,9 +74,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -94,9 +90,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -113,9 +106,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -132,9 +122,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "web_search", @@ -150,9 +137,6 @@ async fn model_selects_expected_tools() { expected_default_tools( "shell_command", &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 59c7dde7d7..e9f0a73f98 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -6,7 +6,6 @@ use chrono::DateTime; use chrono::TimeZone; use chrono::Utc; use codex_core::CodexAuth; -use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -57,7 +56,6 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); builder = builder.with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5".to_string()); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(1); @@ -70,7 +68,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { // Populate cache via initial refresh. let models_manager = test.thread_manager.get_models_manager(); let _ = models_manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; let cache_path = config.codex_home.join(CACHE_FILE); @@ -123,7 +121,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { // Cached models remain usable offline. let offline_models = test .thread_manager - .list_models(&config, RefreshStrategy::Offline) + .list_models(RefreshStrategy::Offline) .await; assert!( offline_models @@ -160,14 +158,13 @@ async fn uses_cache_when_version_matches() -> Result<()> { write_cache_sync(&cache_path, &cache).expect("write cache"); }) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model_provider.request_max_retries = Some(0); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let models = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; assert!( @@ -208,14 +205,13 @@ async fn refreshes_when_cache_version_missing() -> Result<()> { write_cache_sync(&cache_path, &cache).expect("write cache"); }) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model_provider.request_max_retries = Some(0); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let models = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; assert!( @@ -257,14 +253,13 @@ async fn refreshes_when_cache_version_differs() -> Result<()> { write_cache_sync(&cache_path, &cache).expect("write cache"); }) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model_provider.request_max_retries = Some(0); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); let models = models_manager - .list_models(&test.config, RefreshStrategy::OnlineIfUncached) + .list_models(RefreshStrategy::OnlineIfUncached) .await; assert!( @@ -352,5 +347,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, } } diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 413616b9dc..13c31cd7f9 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use anyhow::Result; use codex_core::CodexAuth; -use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -48,7 +47,6 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch .with_auth(auth) .with_model("gpt-5") .with_config(|config| { - config.features.enable(Feature::RemoteModels); // Keep this test deterministic: no request retries, and a small stream retry budget. config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(1); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d272036067..5db3bf9f0a 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -488,7 +488,6 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { &sandbox_policy, AskForApproval::OnRequest, &Policy::empty(), - true, test.config.cwd.as_path(), ) .into_text(); diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9397765c90..d15147ed36 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -81,7 +81,6 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -127,7 +126,6 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.personality = Some(Personality::Friendly); }); @@ -181,7 +179,6 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.personality = Some(Personality::None); }); @@ -242,7 +239,6 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -290,7 +286,6 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -386,7 +381,6 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.personality = Some(Personality::Pragmatic); }); @@ -494,7 +488,6 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.disable(Feature::RemoteModels); config.features.disable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -570,122 +563,6 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ignores_remote_personality_if_remote_models_disabled() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = MockServer::builder() - .body_print_limit(BodyPrintLimit::Limited(80_000)) - .start() - .await; - - let remote_slug = "gpt-5.2-codex"; - let remote_personality_message = "Friendly from remote template"; - let remote_model = ModelInfo { - slug: remote_slug.to_string(), - display_name: "Remote personality test".to_string(), - description: Some("Remote model with personality template".to_string()), - default_reasoning_level: Some(ReasoningEffort::Medium), - supported_reasoning_levels: vec![ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: ReasoningEffort::Medium.to_string(), - }], - shell_type: ConfigShellToolType::UnifiedExec, - visibility: ModelVisibility::List, - supported_in_api: true, - priority: 1, - upgrade: None, - base_instructions: "base instructions".to_string(), - model_messages: Some(ModelMessages { - instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()), - instructions_variables: Some(ModelInstructionsVariables { - personality_default: None, - personality_friendly: Some(remote_personality_message.to_string()), - personality_pragmatic: None, - }), - }), - supports_reasoning_summaries: false, - support_verbosity: false, - default_verbosity: None, - apply_patch_tool_type: None, - truncation_policy: TruncationPolicyConfig::bytes(10_000), - supports_parallel_tool_calls: false, - context_window: Some(128_000), - auto_compact_token_limit: None, - effective_context_window_percent: 95, - experimental_supported_tools: Vec::new(), - input_modalities: default_input_modalities(), - prefer_websockets: false, - }; - - let _models_mock = mount_models_once( - &server, - ModelsResponse { - models: vec![remote_model], - }, - ) - .await; - - let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; - - let mut builder = test_codex() - .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(|config| { - config.features.disable(Feature::RemoteModels); - config.features.enable(Feature::Personality); - config.model = Some(remote_slug.to_string()); - config.personality = Some(Personality::Friendly); - }); - let test = builder.build(&server).await?; - - wait_for_model_available( - &test.thread_manager.get_models_manager(), - remote_slug, - &test.config, - ) - .await; - - test.codex - .submit(Op::UserTurn { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - model: remote_slug.to_string(), - effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, - collaboration_mode: None, - personality: None, - }) - .await?; - - wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - - let request = resp_mock.single_request(); - let instructions_text = request.instructions_text(); - - assert!( - instructions_text.contains("You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."), - "expected instructions to use the template instructions, got: {instructions_text:?}" - ); - assert!( - instructions_text.contains( - "You optimize for team morale and being a supportive teammate as much as code quality." - ), - "expected instructions to include the local friendly personality template, got: {instructions_text:?}" - ); - assert!( - !instructions_text.contains("{{ personality }}"), - "expected legacy personality placeholder to be replaced, got: {instructions_text:?}" - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -733,6 +610,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, }; let _models_mock = mount_models_once( @@ -748,19 +626,13 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow let mut builder = test_codex() .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.model = Some(remote_slug.to_string()); config.personality = Some(Personality::Friendly); }); let test = builder.build(&server).await?; - wait_for_model_available( - &test.thread_manager.get_models_manager(), - remote_slug, - &test.config, - ) - .await; + wait_for_model_available(&test.thread_manager.get_models_manager(), remote_slug).await; test.codex .submit(Op::UserTurn { @@ -845,6 +717,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, }; let _models_mock = mount_models_once( @@ -864,18 +737,12 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - let mut builder = test_codex() .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.features.enable(Feature::Personality); config.model = Some("gpt-5.2-codex".to_string()); }); let test = builder.build(&server).await?; - wait_for_model_available( - &test.thread_manager.get_models_manager(), - remote_slug, - &test.config, - ) - .await; + wait_for_model_available(&test.thread_manager.get_models_manager(), remote_slug).await; test.codex .submit(Op::UserTurn { @@ -954,16 +821,10 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - Ok(()) } -async fn wait_for_model_available( - manager: &Arc, - slug: &str, - config: &codex_core::config::Config, -) { +async fn wait_for_model_available(manager: &Arc, slug: &str) { let deadline = Instant::now() + Duration::from_secs(2); loop { - let models = manager - .list_models(config, RefreshStrategy::OnlineIfUncached) - .await; + let models = manager.list_models(RefreshStrategy::OnlineIfUncached).await; if models.iter().any(|model| model.model == slug) { return; } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 97fe9b4161..cd3b4272a0 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -144,9 +144,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { vec!["exec_command", "write_stdin"] }; expected_tools_names.extend([ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", "update_plan", "request_user_input", "apply_patch", @@ -197,7 +194,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.enable(Feature::RemoteModels); config.features.disable(Feature::ApplyPatchFreeform); config.features.enable(Feature::CollaborationModes); config.model = Some("gpt-5".to_string()); diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 3af3a5952b..110150d447 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -7,8 +7,6 @@ use anyhow::Result; use codex_core::CodexAuth; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; -use codex_core::config::Config; -use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::AskForApproval; @@ -92,8 +90,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); + let config = load_default_config_for_test(&codex_home).await; let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -106,9 +103,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( provider, ); - manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + manager.list_models(RefreshStrategy::OnlineIfUncached).await; let model_info = manager.get_model_info("gpt-5.3-codex-test", &config).await; @@ -163,7 +158,6 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some(requested_model.to_string()); }) .build(&server) @@ -225,6 +219,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), @@ -252,7 +247,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); }); let TestCodex { @@ -264,8 +258,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { } = builder.build(&server).await?; let models_manager = thread_manager.get_models_manager(); - let available_model = - wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG, &config).await; + let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; assert_eq!(available_model.model, REMOTE_MODEL_SLUG); @@ -374,13 +367,12 @@ async fn remote_models_truncation_policy_without_override_preserves_remote() -> let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); - wait_for_model_available(&models_manager, slug, &test.config).await; + wait_for_model_available(&models_manager, slug).await; let model_info = models_manager.get_model_info(slug, &test.config).await; assert_eq!( @@ -419,14 +411,13 @@ async fn remote_models_truncation_policy_with_tool_output_override() -> Result<( let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); config.tool_output_token_limit = Some(50); }); let test = builder.build(&server).await?; let models_manager = test.thread_manager.get_models_manager(); - wait_for_model_available(&models_manager, slug, &test.config).await; + wait_for_model_available(&models_manager, slug).await; let model_info = models_manager.get_model_info(slug, &test.config).await; assert_eq!( @@ -464,6 +455,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: remote_base.to_string(), @@ -500,7 +492,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some("gpt-5.1".to_string()); }); let TestCodex { @@ -512,7 +503,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { } = builder.build(&server).await?; let models_manager = thread_manager.get_models_manager(); - wait_for_model_available(&models_manager, model, &config).await; + wait_for_model_available(&models_manager, model).await; codex .submit(Op::OverrideTurnContext { @@ -572,8 +563,6 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -586,9 +575,7 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let remote = available .iter() .find(|model| model.model == "remote-alpha") @@ -637,8 +624,6 @@ async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -651,9 +636,7 @@ async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; assert_eq!( available.first().map(|model| model.model.as_str()), Some("remote-top") @@ -688,8 +671,6 @@ async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -702,9 +683,7 @@ async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let overridden = available .iter() .find(|model| model.model == slug) @@ -736,8 +715,6 @@ async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Res let _models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -750,9 +727,7 @@ async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Res provider, ); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let bundled_slug = bundled_model_slug(); assert!( available.iter().any(|model| model.model == bundled_slug), @@ -781,8 +756,6 @@ async fn remote_models_request_times_out_after_5s() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -798,7 +771,7 @@ async fn remote_models_request_times_out_after_5s() -> Result<()> { let start = Instant::now(); let model = timeout( Duration::from_secs(7), - manager.get_default_model(&None, &config, RefreshStrategy::OnlineIfUncached), + manager.get_default_model(&None, RefreshStrategy::OnlineIfUncached), ) .await; let elapsed = start.elapsed(); @@ -848,8 +821,6 @@ async fn remote_models_hide_picker_only_models() -> Result<()> { .await; let codex_home = TempDir::new()?; - let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::RemoteModels); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { @@ -863,13 +834,11 @@ async fn remote_models_hide_picker_only_models() -> Result<()> { ); let selected = manager - .get_default_model(&None, &config, RefreshStrategy::OnlineIfUncached) + .get_default_model(&None, RefreshStrategy::OnlineIfUncached) .await; assert_eq!(selected, "gpt-5.2-codex"); - let available = manager - .list_models(&config, RefreshStrategy::OnlineIfUncached) - .await; + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; let hidden = available .iter() .find(|model| model.model == "codex-auto-balanced") @@ -886,17 +855,11 @@ async fn remote_models_hide_picker_only_models() -> Result<()> { Ok(()) } -async fn wait_for_model_available( - manager: &Arc, - slug: &str, - config: &Config, -) -> ModelPreset { +async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(model) = { - let guard = manager - .list_models(config, RefreshStrategy::OnlineIfUncached) - .await; + let guard = manager.list_models(RefreshStrategy::OnlineIfUncached).await; guard.iter().find(|model| model.model == slug).cloned() } { return model; @@ -948,6 +911,7 @@ fn test_remote_model_with_policy( supported_in_api: true, input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, priority, upgrade: None, base_instructions: "base instructions".to_string(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f2e9c202b6..61726f913c 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -74,7 +74,14 @@ async fn emits_warning_when_resumed_model_differs() { .expect("resume conversation"); // Assert: a Warning event is emitted describing the model mismatch. - let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await; + let warning = wait_for_event(&conversation, |ev| { + matches!( + ev, + EventMsg::Warning(WarningEvent { message }) + if message.contains("previous-model") && message.contains("current-model") + ) + }) + .await; let EventMsg::Warning(WarningEvent { message }) = warning else { panic!("expected warning event"); }; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 46df9ca17e..d1394f7a74 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -11,7 +11,6 @@ use std::time::UNIX_EPOCH; use codex_core::CodexAuth; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; -use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::AskForApproval; @@ -410,6 +409,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re experimental_supported_tools: Vec::new(), input_modalities: vec![InputModality::Text], prefer_websockets: false, + used_fallback_model_metadata: false, }], }, ) @@ -440,8 +440,6 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re let fixture = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| { - config.features.enable(Feature::RemoteModels); - let mut servers = config.mcp_servers.get().clone(); servers.insert( server_name.to_string(), @@ -477,7 +475,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re fixture .thread_manager .get_models_manager() - .list_models(&fixture.config, RefreshStrategy::Online) + .list_models(RefreshStrategy::Online) .await; assert_eq!(models_mock.requests().len(), 1); diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs new file mode 100644 index 0000000000..62a46bf1de --- /dev/null +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -0,0 +1,252 @@ +use anyhow::Result; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ModelRerouteReason; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_once; +use core_test_support::responses::mount_response_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::sse_completed; +use core_test_support::responses::sse_response; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; + +const SERVER_MODEL: &str = "gpt-5.2"; +const REQUESTED_MODEL: &str = "gpt-5.3-codex"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = + sse_response(sse_completed("resp-1")).insert_header("OpenAI-Model", SERVER_MODEL); + let _mock = mount_response_once(&server, response).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "trigger safety check".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + let reroute = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ModelReroute(_)) + }) + .await; + let EventMsg::ModelReroute(reroute) = reroute else { + panic!("expected model reroute event"); + }; + assert_eq!(reroute.from_model, REQUESTED_MODEL); + assert_eq!(reroute.to_model, SERVER_MODEL); + assert_eq!(reroute.reason, ModelRerouteReason::HighRiskCyberActivity); + + let warning = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Warning(_))).await; + let EventMsg::Warning(warning) = warning else { + panic!("expected warning event"); + }; + assert!(warning.message.contains(REQUESTED_MODEL)); + assert!(warning.message.contains(SERVER_MODEL)); + + let warning_item = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RawResponseItem(raw) + if matches!( + &raw.item, + ResponseItem::Message { content, .. } + if content.iter().any(|item| matches!( + item, + ContentItem::InputText { text } if text.starts_with("Warning: ") + )) + ) + ) + }) + .await; + let EventMsg::RawResponseItem(raw) = warning_item else { + panic!("expected raw response item event"); + }; + let ResponseItem::Message { role, content, .. } = raw.item else { + panic!("expected warning to be recorded as a message item"); + }; + assert_eq!(role, "user"); + let warning_text = content.iter().find_map(|item| match item { + ContentItem::InputText { text } => Some(text.as_str()), + _ => None, + }); + let warning_text = warning_text.expect("warning message should include input_text content"); + assert!(warning_text.contains(REQUESTED_MODEL)); + assert!(warning_text.contains(SERVER_MODEL)); + + let _ = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_model_field_mismatch_emits_warning_when_header_matches_requested() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = sse_response(sse(vec![ + serde_json::json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": SERVER_MODEL, + } + }), + core_test_support::responses::ev_completed("resp-1"), + ])) + .insert_header("OpenAI-Model", REQUESTED_MODEL); + let _mock = mount_response_once(&server, response).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "trigger response model check".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + let reroute = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ModelReroute(_)) + }) + .await; + let EventMsg::ModelReroute(reroute) = reroute else { + panic!("expected model reroute event"); + }; + assert_eq!(reroute.from_model, REQUESTED_MODEL); + assert_eq!(reroute.to_model, SERVER_MODEL); + assert_eq!(reroute.reason, ModelRerouteReason::HighRiskCyberActivity); + + let warning = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Warning(warning) + if warning + .message + .contains("flagged for potentially high-risk cyber activity") + ) + }) + .await; + let EventMsg::Warning(warning) = warning else { + panic!("expected warning event"); + }; + assert!(warning.message.contains(REQUESTED_MODEL)); + assert!(warning.message.contains(SERVER_MODEL)); + + let _ = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_args = serde_json::json!({ + "command": "echo hello", + "timeout_ms": 1_000 + }); + + let first_response = sse_response(sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + "call-1", + "shell_command", + &serde_json::to_string(&tool_args)?, + ), + core_test_support::responses::ev_completed("resp-1"), + ])) + .insert_header("OpenAI-Model", SERVER_MODEL); + let second_response = sse_response(sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + core_test_support::responses::ev_completed("resp-2"), + ])) + .insert_header("OpenAI-Model", SERVER_MODEL); + let _mock = mount_response_sequence(&server, vec![first_response, second_response]).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "trigger follow-up turn".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + let mut warning_count = 0; + loop { + let event = wait_for_event(&test.codex, |_| true).await; + match event { + EventMsg::Warning(warning) if warning.message.contains(REQUESTED_MODEL) => { + warning_count += 1; + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert_eq!(warning_count, 1); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d40c12a427..7d2de4eb9a 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1,14 +1,22 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used, clippy::expect_used)] +use std::sync::Arc; use std::time::Duration; use anyhow::Result; +use codex_core::CodexThread; +use codex_core::NewThread; +use codex_core::config::Config; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -19,15 +27,24 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [ - "apps tools (`mcp__codex_apps__...`) are hidden until you search for them.", - "Matching tools are added to available `tools` and available for the remainder of the current turn.", + "MCP tools of the apps (Calendar) are hidden until you search for them with this tool", + "Matching tools are added to available `tools` and available for the remainder of the current session/thread.", ]; +const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; +const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; +const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events"; +const RMCP_ECHO_TOOL: &str = "mcp__rmcp__echo"; +const RMCP_IMAGE_TOOL: &str = "mcp__rmcp__image"; +const CALENDAR_CREATE_QUERY: &str = "create calendar event"; +const CALENDAR_LIST_QUERY: &str = "list calendar events"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -51,7 +68,7 @@ fn search_tool_description(body: &Value) -> Option { .and_then(Value::as_array) .and_then(|tools| { tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some("search_tool_bm25") { + if tool.get("name").and_then(Value::as_str) == Some(SEARCH_TOOL_BM25_TOOL_NAME) { tool.get("description") .and_then(Value::as_str) .map(str::to_string) @@ -65,9 +82,13 @@ fn search_tool_description(body: &Value) -> Option { fn search_tool_output_payload(request: &ResponsesRequest, call_id: &str) -> Value { let (content, _success) = request .function_call_output_content_and_success(call_id) - .expect("search_tool_bm25 function_call_output should be present"); - let content = content.expect("search_tool_bm25 output should include content"); - serde_json::from_str(&content).expect("search_tool_bm25 content should be valid JSON") + .unwrap_or_else(|| { + panic!("{SEARCH_TOOL_BM25_TOOL_NAME} function_call_output should be present") + }); + let content = content + .unwrap_or_else(|| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} output should include content")); + serde_json::from_str(&content) + .unwrap_or_else(|_| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} content should be valid JSON")) } fn active_selected_tools(payload: &Value) -> Vec { @@ -95,11 +116,70 @@ fn search_result_tools(payload: &Value) -> Vec<&Value> { .collect() } +fn rmcp_server_config(command: String) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command, + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + } +} + +fn configure_apps_with_optional_rmcp( + config: &mut Config, + apps_base_url: &str, + rmcp_server_bin: Option, +) { + config.features.enable(Feature::Apps); + config.features.disable(Feature::AppsMcpGateway); + config.chatgpt_base_url = apps_base_url.to_string(); + if let Some(command) = rmcp_server_bin { + let mut servers = config.mcp_servers.get().clone(); + servers.insert("rmcp".to_string(), rmcp_server_config(command)); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + } +} + +fn configured_builder(apps_base_url: String, rmcp_server_bin: Option) -> TestCodexBuilder { + test_codex().with_config(move |config| { + configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), rmcp_server_bin); + }) +} + +async fn submit_user_input(thread: &Arc, text: &str) -> Result<()> { + thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_flag_adds_tool() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let mock = mount_sse_sequence( &server, vec![sse(vec![ @@ -110,9 +190,7 @@ async fn search_tool_flag_adds_tool() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Apps); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -125,8 +203,8 @@ async fn search_tool_flag_adds_tool() -> Result<()> { let body = mock.single_request().body_json(); let tools = tool_names(&body); assert!( - tools.iter().any(|name| name == "search_tool_bm25"), - "tools list should include search_tool_bm25 when enabled: {tools:?}" + tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), + "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} when enabled: {tools:?}" ); Ok(()) @@ -137,6 +215,7 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let mock = mount_sse_sequence( &server, vec![sse(vec![ @@ -147,9 +226,7 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result ) .await; - let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Apps); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -160,23 +237,24 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result .await?; let body = mock.single_request().body_json(); - let search_tool_description = - search_tool_description(&body).expect("search tool description should be present"); + let description = search_tool_description(&body).expect("search tool description should exist"); assert!( SEARCH_TOOL_INSTRUCTION_SNIPPETS .iter() - .all(|snippet| search_tool_description.contains(snippet)), - "search tool description should include search tool workflow: {search_tool_description:?}" + .all(|snippet| description.contains(snippet)), + "search tool description should include search tool workflow: {description:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_hides_mcp_tools_without_search() -> Result<()> { +async fn search_tool_hides_apps_tools_without_search_but_keeps_non_app_tools_visible() -> Result<()> +{ skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let mock = mount_sse_sequence( &server, vec![sse(vec![ @@ -188,34 +266,10 @@ async fn search_tool_hides_mcp_tools_without_search() -> Result<()> { .await; let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::Apps); - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - "rmcp".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }); + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); let test = builder.build(&server).await?; test.submit_turn_with_policies( @@ -228,35 +282,96 @@ async fn search_tool_hides_mcp_tools_without_search() -> Result<()> { let body = mock.single_request().body_json(); let tools = tool_names(&body); assert!( - tools.iter().any(|name| name == "search_tool_bm25"), - "tools list should include search_tool_bm25 when enabled: {tools:?}" + tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), + "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME}: {tools:?}" ); assert!( - !tools.iter().any(|name| name == "mcp__rmcp__echo"), - "tools list should not include MCP tools before search: {tools:?}" + tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible in Apps mode: {tools:?}" ); assert!( - !tools.iter().any(|name| name == "mcp__rmcp__image"), - "tools list should not include MCP tools before search: {tools:?}" + tools.iter().any(|name| name == RMCP_IMAGE_TOOL), + "non-app MCP tools should remain visible in Apps mode: {tools:?}" + ); + assert!( + !tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should stay hidden before search/mention: {tools:?}" + ); + assert!( + !tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "apps tools should stay hidden before search/mention: {tools:?}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Result<()> { +async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let mock = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ])], + ) + .await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "Use [$calendar](app://calendar) and then call tools.", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!( + tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible: {tools:?}" + ); + assert!( + tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should be available after explicit app mention: {tools:?}" + ); + assert!( + tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "apps tools should be available after explicit app mention: {tools:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_persists_across_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; let call_id = "tool-search"; let args = json!({ - "query": "echo", + "query": CALENDAR_CREATE_QUERY, "limit": 1, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "search_tool_bm25", &serde_json::to_string(&args)?), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), ev_completed("resp-1"), ]), sse(vec![ @@ -271,38 +386,14 @@ async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Re let mock = mount_sse_sequence(&server, responses).await; let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::Apps); - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - "rmcp".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }); + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); let test = builder.build(&server).await?; test.submit_turn_with_policies( - "find the echo tool", + "find calendar create tool", AskForApproval::Never, SandboxPolicy::DangerFullAccess, ) @@ -324,18 +415,12 @@ async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Re let first_tools = tool_names(&requests[0].body_json()); assert!( - !first_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "first request should not include MCP tools before search: {first_tools:?}" - ); - - let second_tools = tool_names(&requests[1].body_json()); - assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "second request should not include rmcp MCP tools after search: {second_tools:?}" + first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should be available before search: {first_tools:?}" ); assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__image"), - "second request should not include rmcp MCP tools after search: {second_tools:?}" + !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should not be visible before search: {first_tools:?}" ); let search_output_payload = search_tool_output_payload(&requests[1], call_id); @@ -350,18 +435,44 @@ async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Re "search results should only include codex_apps tools: {search_output_payload:?}" ); } + + let selected_tools = active_selected_tools(&search_output_payload); assert!( - !active_selected_tools(&search_output_payload) + selected_tools + .iter() + .any(|tool| tool == CALENDAR_CREATE_TOOL), + "calendar create tool should be selected: {search_output_payload:?}" + ); + assert!( + !selected_tools .iter() .any(|tool_name| tool_name.starts_with("mcp__rmcp__")), "search should not add rmcp tools to active selection: {search_output_payload:?}" ); + let second_tools = tool_names(&requests[1].body_json()); + assert!( + second_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible after search: {second_tools:?}" + ); + for selected_tool in &selected_tools { + assert!( + second_tools.iter().any(|name| name == selected_tool), + "follow-up request should include selected tool {selected_tool:?}: {second_tools:?}" + ); + } + let third_tools = tool_names(&requests[2].body_json()); assert!( - !third_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "third request should not include MCP tools after turn reset: {third_tools:?}" + third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible on later turns: {third_tools:?}" ); + for selected_tool in &selected_tools { + assert!( + third_tools.iter().any(|name| name == selected_tool), + "subsequent turn should include selected tool {selected_tool:?}: {third_tools:?}" + ); + } Ok(()) } @@ -371,14 +482,15 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let first_call_id = "tool-search-echo"; - let second_call_id = "tool-search-image"; + let apps_server = AppsTestServer::mount(&server).await?; + let first_call_id = "tool-search-create"; + let second_call_id = "tool-search-list"; let first_args = json!({ - "query": "echo", + "query": CALENDAR_CREATE_QUERY, "limit": 1, }); let second_args = json!({ - "query": "image", + "query": CALENDAR_LIST_QUERY, "limit": 1, }); let responses = vec![ @@ -386,7 +498,7 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { ev_response_created("resp-1"), ev_function_call( first_call_id, - "search_tool_bm25", + SEARCH_TOOL_BM25_TOOL_NAME, &serde_json::to_string(&first_args)?, ), ev_completed("resp-1"), @@ -395,7 +507,7 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { ev_response_created("resp-2"), ev_function_call( second_call_id, - "search_tool_bm25", + SEARCH_TOOL_BM25_TOOL_NAME, &serde_json::to_string(&second_args)?, ), ev_completed("resp-2"), @@ -408,38 +520,14 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { let mock = mount_sse_sequence(&server, responses).await; let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::Apps); - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - "rmcp".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }); + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); let test = builder.build(&server).await?; test.submit_turn_with_policies( - "find echo and image tools", + "find create and list calendar tools", AskForApproval::Never, SandboxPolicy::DangerFullAccess, ) @@ -455,28 +543,16 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { let first_tools = tool_names(&requests[0].body_json()); assert!( - !first_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "first request should not include MCP tools before search: {first_tools:?}" - ); - - let second_tools = tool_names(&requests[1].body_json()); - assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "second request should not include rmcp tools after first search: {second_tools:?}" + first_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should be visible before search: {first_tools:?}" ); assert!( - !second_tools.iter().any(|name| name == "mcp__rmcp__image"), - "second request should not include rmcp tools after first search: {second_tools:?}" - ); - - let third_tools = tool_names(&requests[2].body_json()); - assert!( - !third_tools.iter().any(|name| name == "mcp__rmcp__echo"), - "third request should not include rmcp tools: {third_tools:?}" + !first_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "apps tools should be hidden before search: {first_tools:?}" ); assert!( - !third_tools.iter().any(|name| name == "mcp__rmcp__image"), - "third request should not include rmcp tools: {third_tools:?}" + !first_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "apps tools should be hidden before search: {first_tools:?}" ); let second_search_payload = search_tool_output_payload(&requests[2], second_call_id); @@ -491,11 +567,495 @@ async fn search_tool_selection_unions_results_within_turn() -> Result<()> { "search results should only include codex_apps tools: {second_search_payload:?}" ); } + + let selected_tools = active_selected_tools(&second_search_payload); + assert_eq!( + selected_tools, + vec![ + CALENDAR_CREATE_TOOL.to_string(), + CALENDAR_LIST_TOOL.to_string(), + ], + "two searches in one turn should union selected apps tools" + ); + + let third_tools = tool_names(&requests[2].body_json()); assert!( - !active_selected_tools(&second_search_payload) - .iter() - .any(|tool_name| tool_name.starts_with("mcp__rmcp__")), - "search should not add rmcp tools to active selection: {second_search_payload:?}" + third_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app MCP tools should remain visible after repeated search: {third_tools:?}" + ); + assert!( + third_tools.iter().any(|name| name == CALENDAR_CREATE_TOOL), + "calendar create should be available after repeated search: {third_tools:?}" + ); + assert!( + third_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "calendar list should be available after repeated search: {third_tools:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_restores_when_resumed() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search"; + let args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "resumed done"), + ev_completed("resp-3"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin.clone()), + ); + let test = builder.build(&server).await?; + + let home = test.home.clone(); + let rollout_path = test + .session_configured + .rollout_path + .clone() + .expect("rollout path should be available for resume"); + + test.submit_turn_with_policies( + "find calendar tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests after initial turn, got {}", + requests.len() + ); + let search_output_payload = search_tool_output_payload(&requests[1], call_id); + let selected_tools = active_selected_tools(&search_output_payload); + assert!( + selected_tools + .iter() + .any(|name| name == CALENDAR_CREATE_TOOL), + "search should select calendar create before resume: {search_output_payload:?}" + ); + + let mut resume_builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .submit_turn_with_policies( + "hello after resume", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected 3 requests after resumed turn, got {}", + requests.len() + ); + let resumed_tools = tool_names(&requests[2].body_json()); + assert!( + resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible after resume: {resumed_tools:?}" + ); + for selected_tool in &selected_tools { + assert!( + resumed_tools.iter().any(|name| name == selected_tool), + "resumed request should include restored selected tool {selected_tool:?}: {resumed_tools:?}" + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_union_restores_when_resumed_after_multiple_search_calls() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let first_call_id = "tool-search-create"; + let second_call_id = "tool-search-list"; + let first_args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let second_args = json!({ + "query": CALENDAR_LIST_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + first_call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&first_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "first search done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + second_call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&second_args)?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-2", "second search done"), + ev_completed("resp-4"), + ]), + sse(vec![ + ev_response_created("resp-5"), + ev_assistant_message("msg-3", "resumed done"), + ev_completed("resp-5"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin.clone()), + ); + let test = builder.build(&server).await?; + + let home = test.home.clone(); + let rollout_path = test + .session_configured + .rollout_path + .clone() + .expect("rollout path should be available for resume"); + + test.submit_turn_with_policies( + "find create calendar tool", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + test.submit_turn_with_policies( + "find list calendar tool", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 4, + "expected 4 requests before resume, got {}", + requests.len() + ); + + let first_search_payload = search_tool_output_payload(&requests[1], first_call_id); + let first_result_tools = search_result_tools(&first_search_payload); + assert_eq!( + first_result_tools.len(), + 1, + "first search should return exactly one tool: {first_search_payload:?}" + ); + assert_eq!( + first_result_tools[0].get("name").and_then(Value::as_str), + Some(CALENDAR_CREATE_TOOL), + "first search should return calendar create tool: {first_search_payload:?}" + ); + let first_selected_tools = active_selected_tools(&first_search_payload); + assert_eq!( + first_selected_tools, + vec![CALENDAR_CREATE_TOOL.to_string()], + "first search should only select create tool: {first_search_payload:?}" + ); + + let second_search_payload = search_tool_output_payload(&requests[3], second_call_id); + let second_result_tools = search_result_tools(&second_search_payload); + assert_eq!( + second_result_tools.len(), + 1, + "second search should return exactly one tool: {second_search_payload:?}" + ); + assert_eq!( + second_result_tools[0].get("name").and_then(Value::as_str), + Some(CALENDAR_LIST_TOOL), + "second search should return calendar list tool: {second_search_payload:?}" + ); + let second_selected_tools = active_selected_tools(&second_search_payload); + assert_eq!( + second_selected_tools, + vec![ + CALENDAR_CREATE_TOOL.to_string(), + CALENDAR_LIST_TOOL.to_string(), + ], + "multiple searches should persist union before resume: {second_search_payload:?}" + ); + + let mut resume_builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .submit_turn_with_policies( + "hello after resume with union", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 5, + "expected 5 requests after resumed turn, got {}", + requests.len() + ); + + let resumed_tools = tool_names(&requests[4].body_json()); + assert!( + resumed_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible after resume: {resumed_tools:?}" + ); + assert!( + resumed_tools + .iter() + .any(|name| name == CALENDAR_CREATE_TOOL), + "resumed turn should restore calendar create tool: {resumed_tools:?}" + ); + assert!( + resumed_tools.iter().any(|name| name == CALENDAR_LIST_TOOL), + "resumed turn should restore calendar list tool: {resumed_tools:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_restores_when_forked_with_full_history() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search"; + let args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "forked done"), + ev_completed("resp-3"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find calendar tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests after initial turn, got {}", + requests.len() + ); + let search_output_payload = search_tool_output_payload(&requests[1], call_id); + let selected_tools = active_selected_tools(&search_output_payload); + assert!( + selected_tools + .iter() + .any(|name| name == CALENDAR_CREATE_TOOL), + "search should select calendar create: {search_output_payload:?}" + ); + + let rollout_path = test + .codex + .rollout_path() + .expect("rollout path should exist for fork"); + let NewThread { thread: forked, .. } = test + .thread_manager + .fork_thread(usize::MAX, test.config.clone(), rollout_path, false) + .await?; + submit_user_input(&forked, "hello after fork").await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected 3 requests after forked turn, got {}", + requests.len() + ); + let forked_tools = tool_names(&requests[2].body_json()); + assert!( + forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible in forked thread: {forked_tools:?}" + ); + for selected_tool in &selected_tools { + assert!( + forked_tools.iter().any(|name| name == selected_tool), + "forked request should include restored selected tool {selected_tool:?}: {forked_tools:?}" + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_selection_drops_when_fork_excludes_search_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "tool-search"; + let args = json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "forked done"), + ev_completed("resp-3"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = configured_builder( + apps_server.chatgpt_base_url.clone(), + Some(rmcp_test_server_bin), + ); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find calendar tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests after initial turn, got {}", + requests.len() + ); + let search_output_payload = search_tool_output_payload(&requests[1], call_id); + let selected_tools = active_selected_tools(&search_output_payload); + assert!( + !selected_tools.is_empty(), + "search turn should produce selected tools: {search_output_payload:?}" + ); + + let rollout_path = test + .codex + .rollout_path() + .expect("rollout path should exist for fork"); + let NewThread { thread: forked, .. } = test + .thread_manager + .fork_thread(0, test.config.clone(), rollout_path, false) + .await?; + submit_user_input(&forked, "hello after fork").await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected 3 requests after forked turn, got {}", + requests.len() + ); + let forked_tools = tool_names(&requests[2].body_json()); + assert!( + forked_tools.iter().any(|name| name == RMCP_ECHO_TOOL), + "non-app tools should remain visible in forked thread: {forked_tools:?}" + ); + assert!( + !forked_tools + .iter() + .any(|name| name.starts_with("mcp__codex_apps__")), + "forked history without search turn should not restore apps tools: {forked_tools:?}" ); Ok(()) diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 42a2107cd1..b5007fd589 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -20,6 +20,7 @@ use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use tokio::fs; @@ -36,6 +37,17 @@ struct SnapshotRun { codex_home: PathBuf, } +const POLICY_PATH_FOR_TEST: &str = "/codex/policy/path"; +const SNAPSHOT_PATH_FOR_TEST: &str = "/codex/snapshot/path"; +const SNAPSHOT_MARKER_VAR: &str = "CODEX_SNAPSHOT_POLICY_MARKER"; +const SNAPSHOT_MARKER_VALUE: &str = "from_snapshot"; +const POLICY_SUCCESS_OUTPUT: &str = "policy-after-snapshot"; + +#[derive(Debug, Default)] +struct SnapshotRunOptions { + shell_environment_set: HashMap, +} + async fn wait_for_snapshot(codex_home: &Path) -> Result { let snapshot_dir = codex_home.join("shell_snapshots"); let deadline = Instant::now() + Duration::from_secs(5); @@ -54,12 +66,57 @@ async fn wait_for_snapshot(codex_home: &Path) -> Result { } } +async fn wait_for_file_contents(path: &Path) -> Result { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match fs::read_to_string(path).await { + Ok(contents) => return Ok(contents), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for file {}", path.display()); + } + + sleep(Duration::from_millis(25)).await; + } +} + +fn policy_set_path_for_test() -> HashMap { + HashMap::from([("PATH".to_string(), POLICY_PATH_FOR_TEST.to_string())]) +} + +fn snapshot_override_content_for_policy_test() -> String { + format!( + "# Snapshot file\nexport PATH='{SNAPSHOT_PATH_FOR_TEST}'\nexport {SNAPSHOT_MARKER_VAR}='{SNAPSHOT_MARKER_VALUE}'\n" + ) +} + +fn command_asserting_policy_after_snapshot() -> String { + format!( + "if [ \"${{{SNAPSHOT_MARKER_VAR}:-}}\" = \"{SNAPSHOT_MARKER_VALUE}\" ] && [ \"$PATH\" != \"{SNAPSHOT_PATH_FOR_TEST}\" ]; then case \":$PATH:\" in *\":{POLICY_PATH_FOR_TEST}:\"*) printf \"{POLICY_SUCCESS_OUTPUT}\" ;; *) printf \"path=%s marker=%s\" \"$PATH\" \"${{{SNAPSHOT_MARKER_VAR}:-missing}}\" ;; esac; else printf \"path=%s marker=%s\" \"$PATH\" \"${{{SNAPSHOT_MARKER_VAR}:-missing}}\"; fi" + ) +} + #[allow(clippy::expect_used)] async fn run_snapshot_command(command: &str) -> Result { - let builder = test_codex().with_config(|config| { + run_snapshot_command_with_options(command, SnapshotRunOptions::default()).await +} + +#[allow(clippy::expect_used)] +async fn run_snapshot_command_with_options( + command: &str, + options: SnapshotRunOptions, +) -> Result { + let SnapshotRunOptions { + shell_environment_set, + } = options; + let builder = test_codex().with_config(move |config| { config.use_experimental_unified_exec_tool = true; config.features.enable(Feature::UnifiedExec); config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = shell_environment_set; }); let harness = TestCodexHarness::with_builder(builder).await?; let args = json!({ @@ -132,8 +189,20 @@ async fn run_snapshot_command(command: &str) -> Result { #[allow(clippy::expect_used)] async fn run_shell_command_snapshot(command: &str) -> Result { - let builder = test_codex().with_config(|config| { + run_shell_command_snapshot_with_options(command, SnapshotRunOptions::default()).await +} + +#[allow(clippy::expect_used)] +async fn run_shell_command_snapshot_with_options( + command: &str, + options: SnapshotRunOptions, +) -> Result { + let SnapshotRunOptions { + shell_environment_set, + } = options; + let builder = test_codex().with_config(move |config| { config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = shell_environment_set; }); let harness = TestCodexHarness::with_builder(builder).await?; let args = json!({ @@ -204,6 +273,64 @@ async fn run_shell_command_snapshot(command: &str) -> Result { }) } +#[allow(clippy::expect_used)] +async fn run_tool_turn_on_harness( + harness: &TestCodexHarness, + prompt: &str, + call_id: &str, + tool_name: &str, + args: serde_json::Value, +) -> Result { + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, tool_name, &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), responses).await; + + let test = harness.test(); + let codex = test.codex.clone(); + let session_model = test.session_configured.model.clone(); + let cwd = test.cwd_path().to_path_buf(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + let end = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + Ok(end) +} + fn normalize_newlines(text: &str) -> String { text.replace("\r\n", "\n") } @@ -260,6 +387,100 @@ async fn linux_shell_command_uses_shell_snapshot() -> Result<()> { Ok(()) } +#[cfg_attr(target_os = "windows", ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_snapshot_preserves_shell_environment_policy_set() -> Result<()> { + let builder = test_codex().with_config(|config| { + config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = policy_set_path_for_test(); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let codex_home = harness.test().home.path().to_path_buf(); + run_tool_turn_on_harness( + &harness, + "warm up shell snapshot", + "shell-snapshot-policy-warmup", + "shell_command", + json!({ + "command": "printf warmup", + "timeout_ms": 1_000, + }), + ) + .await?; + let snapshot_path = wait_for_snapshot(&codex_home).await?; + fs::write(&snapshot_path, snapshot_override_content_for_policy_test()).await?; + + let command = command_asserting_policy_after_snapshot(); + let end = run_tool_turn_on_harness( + &harness, + "verify shell policy after snapshot", + "shell-snapshot-policy-assert", + "shell_command", + json!({ + "command": command, + "timeout_ms": 1_000, + }), + ) + .await?; + + assert_eq!( + normalize_newlines(&end.stdout).trim(), + POLICY_SUCCESS_OUTPUT + ); + assert_eq!(end.exit_code, 0); + assert!(snapshot_path.starts_with(codex_home)); + + Ok(()) +} + +#[cfg_attr(not(target_os = "linux"), ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() -> Result<()> { + let builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + config.features.enable(Feature::ShellSnapshot); + config.permissions.shell_environment_policy.r#set = policy_set_path_for_test(); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let codex_home = harness.test().home.path().to_path_buf(); + run_tool_turn_on_harness( + &harness, + "warm up unified exec shell snapshot", + "shell-snapshot-policy-warmup-exec", + "exec_command", + json!({ + "cmd": "printf warmup", + "yield_time_ms": 1_000, + }), + ) + .await?; + let snapshot_path = wait_for_snapshot(&codex_home).await?; + fs::write(&snapshot_path, snapshot_override_content_for_policy_test()).await?; + + let command = command_asserting_policy_after_snapshot(); + let end = run_tool_turn_on_harness( + &harness, + "verify unified exec policy after snapshot", + "shell-snapshot-policy-assert-exec", + "exec_command", + json!({ + "cmd": command, + "yield_time_ms": 1_000, + }), + ) + .await?; + + assert_eq!( + normalize_newlines(&end.stdout).trim(), + POLICY_SUCCESS_OUTPUT + ); + assert_eq!(end.exit_code, 0); + assert!(snapshot_path.starts_with(codex_home)); + + Ok(()) +} + #[cfg_attr(target_os = "windows", ignore)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { @@ -316,7 +537,10 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - assert_eq!(fs::read_to_string(&target).await?, "hello from snapshot\n"); + assert_eq!( + wait_for_file_contents(&target).await?, + "hello from snapshot\n" + ); let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap new file mode 100644 index 0000000000..bcca5d3575 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap @@ -0,0 +1,21 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message.\",\n&[(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" +--- +Scenario: Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:first manual turn +04:message/assistant:FIRST_REPLY +05:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:first manual turn +04:message/user:\nFIRST_MANUAL_SUMMARY +05:message/user:second manual turn diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap new file mode 100644 index 0000000000..bdb4fe9bae --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap @@ -0,0 +1,18 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Compaction Request\", &requests[0]),\n(\"Local Post-Compaction History Layout\", &requests[1]),])" +--- +Scenario: Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:\nMANUAL_EMPTY_SUMMARY +04:message/user:AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap new file mode 100644 index 0000000000..c13f78aff9 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap @@ -0,0 +1,22 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 2646 +expression: "format_labeled_requests_snapshot(\"True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" +--- +Scenario: True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:function call limit push +04:function_call/test_tool +05:function_call_output:unsupported call: test_tool +06:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:function call limit push +04:message/user:\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap new file mode 100644 index 0000000000..8d6c4d9b1a --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap @@ -0,0 +1,31 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 1773 +expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" +--- +Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. + +## Initial Request (Previous Model) +00:message/developer: +01:message/user: +02:message/user:> +03:message/developer: +04:message/user:before switch + +## Pre-sampling Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/developer: +04:message/user:before switch +05:message/assistant:before switch +06:message/user: + +## Post-Compaction Follow-up Request (Next Model) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:before switch +04:message/user:\nPRE_SAMPLING_SUMMARY +05:message/developer:\nThe user was previously using a different model.... +06:message/user:after switch diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap new file mode 100644 index 0000000000..da30b01bd7 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap @@ -0,0 +1,13 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Excluded)\", &requests[1]),])" +--- +Scenario: Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. + +## Local Compaction Request (Incoming User Excluded) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:FIRST_REPLY +05:message/user: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap new file mode 100644 index 0000000000..e586c8521a --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -0,0 +1,25 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" +--- +Scenario: Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:FIRST_REPLY +05:message/user:USER_TWO +06:message/assistant:SECOND_REPLY +07:message/user: +08:message/user: + +## Local Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user: +03:message/user:USER_ONE +04:message/user:USER_TWO +05:message/user:\nPRE_TURN_SUMMARY +06:message/user: | | | USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap new file mode 100644 index 0000000000..bc795a19f4 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap @@ -0,0 +1,21 @@ +--- +source: core/tests/suite/compact_remote.rs +assertion_line: 178 +expression: "format_labeled_requests_snapshot(\"Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", follow_up_request),])" +--- +Scenario: Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:hello remote compact +04:message/assistant:FIRST_REMOTE_REPLY + +## Remote Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:REMOTE_COMPACTED_SUMMARY +04:compaction:encrypted=true +05:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap new file mode 100644 index 0000000000..ce62806d99 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap @@ -0,0 +1,16 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &follow_up_request),])" +--- +Scenario: Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> + +## Remote Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap new file mode 100644 index 0000000000..8ef8701673 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -0,0 +1,20 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +--- +Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:function_call/test_tool +05:function_call_output:unsupported call: test_tool + +## Remote Post-Compaction History Layout +00:message/user:USER_ONE +01:message/developer: +02:message/user: +03:message/user:> +04:message/user:\nREMOTE_MID_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap new file mode 100644 index 0000000000..e718bfda88 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap @@ -0,0 +1,12 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&include_attempt_request),])" +--- +Scenario: Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. + +## Remote Compaction Request (Incoming User Excluded) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:REMOTE_FIRST_REPLY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap new file mode 100644 index 0000000000..f8aa74e9a1 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap @@ -0,0 +1,12 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&first_compact_mock.single_request()),])" +--- +Scenario: Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops. + +## Remote Compaction Request (Incoming User Excluded) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:turn that exceeds token threshold +04:message/assistant:initial turn complete diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap new file mode 100644 index 0000000000..00aaaeaa92 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -0,0 +1,24 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" +--- +Scenario: Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:REMOTE_FIRST_REPLY +05:message/user:USER_TWO +06:message/assistant:REMOTE_SECOND_REPLY +07:message/user: + +## Remote Post-Compaction History Layout +00:message/user:USER_ONE +01:message/user:USER_TWO +02:message/developer: +03:message/user: +04:message/user: +05:message/user:\nREMOTE_PRE_TURN_SUMMARY +06:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index e81592cc5c..bad9efa2d0 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -671,6 +671,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an supported_in_api: true, input_modalities: vec![InputModality::Text], prefer_websockets: false, + used_fallback_model_metadata: false, priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), @@ -697,7 +698,6 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an let TestCodex { codex, cwd, .. } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::RemoteModels); config.model = Some(model_slug.to_string()); }) .build(&server) diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 52c5d523cb..a124688a24 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -23,7 +23,20 @@ As of 1/9/2026, this setup is still experimental as we stabilize it. ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. -The Bazel build should automatically pick things up without any manual action needed. +Then refresh the Bzlmod lockfile from the repo root: + +```bash +just bazel-lock-update +``` + +This runs `bazel mod deps --lockfile_mode=update` and updates `MODULE.bazel.lock` if needed. +Commit the lockfile changes along with your Cargo lockfile update. + +To verify lockfile alignment locally (the same check CI runs), use: + +```bash +just bazel-lock-check +``` In some cases, an upstream crate may need a patch or a `crate.annotation` in `../MODULE.bzl` to have it build in Bazel's sandbox or make it cross-compilation-friendly. If you see issues, diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index f66e92dc8b..6f9e4fbc69 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -93,6 +93,7 @@ impl EscalateServer { expiration: ExecExpiration::Cancellation(cancel_rx), env, network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index d7b95e2882..26861af7c8 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -188,6 +188,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { "warning:".style(self.yellow).style(self.bold) ); } + EventMsg::ModelReroute(_) => {} EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { ts_msg!( self, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 68c88f0000..49163c900b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -374,7 +374,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any )); let default_model = thread_manager .get_models_manager() - .get_default_model(&config.model, &config, RefreshStrategy::OnlineIfUncached) + .get_default_model(&config.model, RefreshStrategy::OnlineIfUncached) .await; // Handle resume subcommand by resolving a rollout path and using explicit resume API. diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 4a227fe093..adda7173a1 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -279,7 +279,7 @@ impl CodexLogSnapshot { } let level = match classification { - "bug" | "bad_result" => Level::Error, + "bug" | "bad_result" | "safety_check" => Level::Error, _ => Level::Info, }; @@ -342,6 +342,7 @@ fn display_classification(classification: &str) -> String { "bug" => "Bug".to_string(), "bad_result" => "Bad result".to_string(), "good_result" => "Good result".to_string(), + "safety_check" => "Safety check".to_string(), _ => "Other".to_string(), } } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index aa7dd58acd..f21fe469e9 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -9,8 +9,9 @@ pub use types::Hook; pub use types::HookEvent; pub use types::HookEventAfterAgent; pub use types::HookEventAfterToolUse; -pub use types::HookOutcome; pub use types::HookPayload; +pub use types::HookResponse; +pub use types::HookResult; pub use types::HookToolInput; pub use types::HookToolInputLocalShell; pub use types::HookToolKind; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 9228bdddd4..6568f5374f 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -2,8 +2,8 @@ use tokio::process::Command; use crate::types::Hook; use crate::types::HookEvent; -use crate::types::HookOutcome; use crate::types::HookPayload; +use crate::types::HookResponse; #[derive(Default, Clone)] pub struct HooksConfig { @@ -45,14 +45,19 @@ impl Hooks { } } - pub async fn dispatch(&self, hook_payload: HookPayload) { - // TODO(gt): support interrupting program execution by returning a result here. - for hook in self.hooks_for_event(&hook_payload.hook_event) { + pub async fn dispatch(&self, hook_payload: HookPayload) -> Vec { + let hooks = self.hooks_for_event(&hook_payload.hook_event); + let mut outcomes = Vec::with_capacity(hooks.len()); + for hook in hooks { let outcome = hook.execute(&hook_payload).await; - if matches!(outcome, HookOutcome::Stop) { + let should_abort_operation = outcome.result.should_abort_operation(); + outcomes.push(outcome); + if should_abort_operation { break; } } + + outcomes } } @@ -88,6 +93,7 @@ mod tests { use super::*; use crate::types::HookEventAfterAgent; use crate::types::HookEventAfterToolUse; + use crate::types::HookResult; use crate::types::HookToolInput; use crate::types::HookToolKind; @@ -113,14 +119,50 @@ mod tests { } } - fn counting_hook(calls: &Arc, outcome: HookOutcome) -> Hook { + fn counting_success_hook(calls: &Arc, name: &str) -> Hook { + let hook_name = name.to_string(); let calls = Arc::clone(calls); Hook { + name: hook_name, func: Arc::new(move |_| { let calls = Arc::clone(&calls); Box::pin(async move { calls.fetch_add(1, Ordering::SeqCst); - outcome + HookResult::Success + }) + }), + } + } + + fn failing_continue_hook(calls: &Arc, name: &str, message: &str) -> Hook { + let hook_name = name.to_string(); + let message = message.to_string(); + let calls = Arc::clone(calls); + Hook { + name: hook_name, + func: Arc::new(move |_| { + let calls = Arc::clone(&calls); + let message = message.clone(); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + HookResult::FailedContinue(std::io::Error::other(message).into()) + }) + }), + } + } + + fn failing_abort_hook(calls: &Arc, name: &str, message: &str) -> Hook { + let hook_name = name.to_string(); + let message = message.to_string(); + let calls = Arc::clone(calls); + Hook { + name: hook_name, + func: Arc::new(move |_| { + let calls = Arc::clone(&calls); + let message = message.clone(); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + HookResult::FailedAbort(std::io::Error::other(message).into()) }) }), } @@ -212,11 +254,14 @@ mod tests { async fn dispatch_executes_hook() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { - after_agent: vec![counting_hook(&calls, HookOutcome::Continue)], + after_agent: vec![counting_success_hook(&calls, "counting")], ..Hooks::default() }; - hooks.dispatch(hook_payload("1")).await; + let outcomes = hooks.dispatch(hook_payload("1")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "counting"); + assert!(matches!(outcomes[0].result, HookResult::Success)); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -224,7 +269,8 @@ mod tests { async fn default_hook_is_noop_and_continues() { let payload = hook_payload("d"); let outcome = Hook::default().execute(&payload).await; - assert_eq!(outcome, HookOutcome::Continue); + assert_eq!(outcome.hook_name, "default"); + assert!(matches!(outcome.result, HookResult::Success)); } #[tokio::test] @@ -232,28 +278,36 @@ mod tests { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { after_agent: vec![ - counting_hook(&calls, HookOutcome::Continue), - counting_hook(&calls, HookOutcome::Continue), + counting_success_hook(&calls, "counting-1"), + counting_success_hook(&calls, "counting-2"), ], ..Hooks::default() }; - hooks.dispatch(hook_payload("2")).await; + let outcomes = hooks.dispatch(hook_payload("2")).await; + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].hook_name, "counting-1"); + assert_eq!(outcomes[1].hook_name, "counting-2"); + assert!(matches!(outcomes[0].result, HookResult::Success)); + assert!(matches!(outcomes[1].result, HookResult::Success)); assert_eq!(calls.load(Ordering::SeqCst), 2); } #[tokio::test] - async fn dispatch_stops_when_hook_returns_stop() { + async fn dispatch_stops_when_hook_requests_abort() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { after_agent: vec![ - counting_hook(&calls, HookOutcome::Stop), - counting_hook(&calls, HookOutcome::Continue), + failing_abort_hook(&calls, "abort", "hook failed"), + counting_success_hook(&calls, "counting"), ], ..Hooks::default() }; - hooks.dispatch(hook_payload("3")).await; + let outcomes = hooks.dispatch(hook_payload("3")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "abort"); + assert!(matches!(outcomes[0].result, HookResult::FailedAbort(_))); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -261,11 +315,53 @@ mod tests { async fn dispatch_executes_after_tool_use_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { - after_tool_use: vec![counting_hook(&calls, HookOutcome::Continue)], + after_tool_use: vec![counting_success_hook(&calls, "counting")], ..Hooks::default() }; - hooks.dispatch(after_tool_use_payload("p")).await; + let outcomes = hooks.dispatch(after_tool_use_payload("p")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "counting"); + assert!(matches!(outcomes[0].result, HookResult::Success)); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dispatch_continues_after_continueable_failure() { + let calls = Arc::new(AtomicUsize::new(0)); + let hooks = Hooks { + after_agent: vec![ + failing_continue_hook(&calls, "failing", "hook failed"), + counting_success_hook(&calls, "counting"), + ], + ..Hooks::default() + }; + + let outcomes = hooks.dispatch(hook_payload("err")).await; + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].hook_name, "failing"); + assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_))); + assert_eq!(outcomes[1].hook_name, "counting"); + assert!(matches!(outcomes[1].result, HookResult::Success)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn dispatch_returns_after_tool_use_failure_outcome() { + let calls = Arc::new(AtomicUsize::new(0)); + let hooks = Hooks { + after_tool_use: vec![failing_continue_hook( + &calls, + "failing", + "after_tool_use hook failed", + )], + ..Hooks::default() + }; + + let outcomes = hooks.dispatch(after_tool_use_payload("err-tool")).await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].hook_name, "failing"); + assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_))); assert_eq!(calls.load(Ordering::SeqCst), 1); } @@ -276,6 +372,7 @@ mod tests { let payload_path = temp_dir.path().join("payload.json"); let payload_path_arg = payload_path.to_string_lossy().into_owned(); let hook = Hook { + name: "write_payload".to_string(), func: Arc::new(move |payload: &HookPayload| { let payload_path_arg = payload_path_arg.clone(); Box::pin(async move { @@ -290,7 +387,7 @@ mod tests { ]) .expect("build command"); command.status().await.expect("run hook command"); - HookOutcome::Continue + HookResult::Success }) }), }; @@ -302,7 +399,9 @@ mod tests { after_agent: vec![hook], ..Hooks::default() }; - hooks.dispatch(payload).await; + let outcomes = hooks.dispatch(payload).await; + assert_eq!(outcomes.len(), 1); + assert!(matches!(outcomes[0].result, HookResult::Success)); let contents = timeout(Duration::from_secs(2), async { loop { @@ -330,6 +429,7 @@ mod tests { fs::write(&script_path, "[IO.File]::WriteAllText($args[0], $args[1])")?; let script_path_arg = script_path.to_string_lossy().into_owned(); let hook = Hook { + name: "write_payload".to_string(), func: Arc::new(move |payload: &HookPayload| { let payload_path_arg = payload_path_arg.clone(); let script_path_arg = script_path_arg.clone(); @@ -348,7 +448,7 @@ mod tests { ]) .expect("build command"); command.status().await.expect("run hook command"); - HookOutcome::Continue + HookResult::Success }) }), }; @@ -360,7 +460,9 @@ mod tests { after_agent: vec![hook], ..Hooks::default() }; - hooks.dispatch(payload).await; + let outcomes = hooks.dispatch(payload).await; + assert_eq!(outcomes.len(), 1); + assert!(matches!(outcomes[0].result, HookResult::Success)); let contents = timeout(Duration::from_secs(2), async { loop { diff --git a/codex-rs/hooks/src/types.rs b/codex-rs/hooks/src/types.rs index a1588b8974..5131580128 100644 --- a/codex-rs/hooks/src/types.rs +++ b/codex-rs/hooks/src/types.rs @@ -10,24 +10,53 @@ use futures::future::BoxFuture; use serde::Serialize; use serde::Serializer; -pub type HookFn = Arc Fn(&'a HookPayload) -> BoxFuture<'a, HookOutcome> + Send + Sync>; +pub type HookFn = Arc Fn(&'a HookPayload) -> BoxFuture<'a, HookResult> + Send + Sync>; + +#[derive(Debug)] +pub enum HookResult { + /// Success: hook completed successfully. + Success, + /// FailedContinue: hook failed, but other subsequent hooks should still execute and the + /// operation should continue. + FailedContinue(Box), + /// FailedAbort: hook failed, other subsequent hooks should not execute, and the operation + /// should be aborted. + FailedAbort(Box), +} + +impl HookResult { + pub fn should_abort_operation(&self) -> bool { + matches!(self, Self::FailedAbort(_)) + } +} + +#[derive(Debug)] +pub struct HookResponse { + pub hook_name: String, + pub result: HookResult, +} #[derive(Clone)] pub struct Hook { + pub name: String, pub func: HookFn, } impl Default for Hook { fn default() -> Self { Self { - func: Arc::new(|_| Box::pin(async { HookOutcome::Continue })), + name: "default".to_string(), + func: Arc::new(|_| Box::pin(async { HookResult::Success })), } } } impl Hook { - pub async fn execute(&self, payload: &HookPayload) -> HookOutcome { - (self.func)(payload).await + pub async fn execute(&self, payload: &HookPayload) -> HookResponse { + HookResponse { + hook_name: self.name.clone(), + result: (self.func)(payload).await, + } } } @@ -126,13 +155,6 @@ pub enum HookEvent { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HookOutcome { - Continue, - #[allow(dead_code)] - Stop, -} - #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/codex-rs/hooks/src/user_notification.rs b/codex-rs/hooks/src/user_notification.rs index d3104a1ec2..50472225e4 100644 --- a/codex-rs/hooks/src/user_notification.rs +++ b/codex-rs/hooks/src/user_notification.rs @@ -6,8 +6,8 @@ use serde::Serialize; use crate::Hook; use crate::HookEvent; -use crate::HookOutcome; use crate::HookPayload; +use crate::HookResult; use crate::command_from_argv; /// Legacy notify payload appended as the final argv argument for backward compatibility. @@ -48,12 +48,13 @@ pub fn legacy_notify_json(hook_event: &HookEvent, cwd: &Path) -> Result) -> Hook { let argv = Arc::new(argv); Hook { + name: "legacy_notify".to_string(), func: Arc::new(move |payload: &HookPayload| { let argv = Arc::clone(&argv); Box::pin(async move { let mut command = match command_from_argv(&argv) { Some(command) => command, - None => return HookOutcome::Continue, + None => return HookResult::Success, }; if let Ok(notify_payload) = legacy_notify_json(&payload.hook_event, &payload.cwd) { command.arg(notify_payload); @@ -65,8 +66,10 @@ pub fn notify_hook(argv: Vec) -> Hook { .stdout(Stdio::null()) .stderr(Stdio::null()); - let _ = command.spawn(); - HookOutcome::Continue + match command.spawn() { + Ok(_) => HookResult::Success, + Err(err) => HookResult::FailedContinue(err.into()), + } }) }), } diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 84fa1d2c3e..fa26a8b969 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -76,6 +76,7 @@ async fn run_cmd_result_with_writable_roots( expiration: timeout_ms.into(), env: create_env_from_core_vars(), network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, justification: None, @@ -123,7 +124,7 @@ async fn should_skip_bwrap_tests() -> bool { .await { Ok(output) => is_bwrap_unavailable_output(&output), - Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => { + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => { is_bwrap_unavailable_output(&output) } // Probe timeouts are not actionable for the bwrap-specific assertions below; @@ -142,7 +143,7 @@ fn expect_denied( assert_ne!(output.exit_code, 0, "{context}: expected nonzero exit code"); output } - Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => *output, + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output, Err(err) => panic!("{context}: {err:?}"), } } @@ -236,6 +237,7 @@ async fn assert_network_blocked(cmd: &[&str]) { expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), network: None, + network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, justification: None, @@ -257,7 +259,7 @@ async fn assert_network_blocked(cmd: &[&str]) { let output = match result { Ok(output) => output, - Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => *output, + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output, _ => { panic!("expected sandbox denied error, got: {result:?}"); } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 6c5b3c25a2..04fa658017 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -221,6 +221,7 @@ async fn run_codex_tool_session_inner( reason: _, proposed_execpolicy_amendment: _, parsed_cmd, + network_approval_context: _, }) => { handle_exec_approval_request( command, @@ -253,6 +254,9 @@ async fn run_codex_tool_session_inner( EventMsg::Warning(_) => { continue; } + EventMsg::ModelReroute(_) => { + continue; + } EventMsg::ElicitationRequest(_) => { // TODO: forward elicitation requests to the client? continue; diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index c898b8f0d7..de8a9306d4 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -507,7 +507,6 @@ request_max_retries = 0 stream_max_retries = 0 [features] -remote_models = false "# ), ) diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 0983b07dc5..536dd4bf6f 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -18,7 +18,9 @@ pub use config::NetworkMode; pub use config::NetworkProxyConfig; pub use config::host_and_port_from_network_addr; pub use network_policy::NetworkDecision; +pub use network_policy::NetworkDecisionSource; pub use network_policy::NetworkPolicyDecider; +pub use network_policy::NetworkPolicyDecision; pub use network_policy::NetworkPolicyRequest; pub use network_policy::NetworkPolicyRequestArgs; pub use network_policy::NetworkProtocol; @@ -34,6 +36,8 @@ pub use proxy::PROXY_URL_ENV_KEYS; pub use proxy::has_proxy_url_env_vars; pub use proxy::proxy_url_env_value; pub use runtime::BlockedRequest; +pub use runtime::BlockedRequestArgs; +pub use runtime::BlockedRequestObserver; pub use runtime::ConfigReloader; pub use runtime::ConfigState; pub use runtime::NetworkProxyState; diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index 201a62cda4..fdbbe92819 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -26,7 +26,8 @@ impl NetworkProtocol { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] pub enum NetworkPolicyDecision { Deny, Ask, @@ -41,7 +42,8 @@ impl NetworkPolicyDecision { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] pub enum NetworkDecisionSource { BaselinePolicy, ModeGuard, diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 42f1637c2d..55a0eaa165 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -3,7 +3,7 @@ use crate::config; use crate::http_proxy; use crate::metadata::proxy_username_for_attempt_id; use crate::network_policy::NetworkPolicyDecider; -use crate::runtime::BlockedRequest; +use crate::runtime::BlockedRequestObserver; use crate::runtime::unix_socket_permissions_supported; use crate::socks5; use crate::state::NetworkProxyState; @@ -72,6 +72,7 @@ pub struct NetworkProxyBuilder { admin_addr: Option, managed_by_codex: bool, policy_decider: Option>, + blocked_request_observer: Option>, } impl Default for NetworkProxyBuilder { @@ -83,6 +84,7 @@ impl Default for NetworkProxyBuilder { admin_addr: None, managed_by_codex: true, policy_decider: None, + blocked_request_observer: None, } } } @@ -126,12 +128,31 @@ impl NetworkProxyBuilder { self } + pub fn blocked_request_observer(mut self, observer: O) -> Self + where + O: BlockedRequestObserver, + { + self.blocked_request_observer = Some(Arc::new(observer)); + self + } + + pub fn blocked_request_observer_arc( + mut self, + observer: Arc, + ) -> Self { + self.blocked_request_observer = Some(observer); + self + } + pub async fn build(self) -> Result { let state = self.state.ok_or_else(|| { anyhow::anyhow!( "NetworkProxyBuilder requires a state; supply one via builder.state(...)" ) })?; + state + .set_blocked_request_observer(self.blocked_request_observer.clone()) + .await; let current_cfg = state.current_cfg().await?; let (requested_http_addr, requested_socks_addr, requested_admin_addr, reserved_listeners) = if self.managed_by_codex { @@ -399,13 +420,6 @@ impl NetworkProxy { self.admin_addr } - pub async fn latest_blocked_request_for_attempt( - &self, - attempt_id: &str, - ) -> Result> { - self.state.latest_blocked_for_attempt(attempt_id).await - } - pub fn apply_to_env(&self, env: &mut HashMap) { self.apply_to_env_for_attempt(env, None); } diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 737a8dd1de..7917747a8f 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -20,6 +20,7 @@ use globset::GlobSet; use serde::Serialize; use std::collections::HashSet; use std::collections::VecDeque; +use std::future::Future; use std::net::IpAddr; use std::path::Path; use std::sync::Arc; @@ -162,9 +163,33 @@ pub trait ConfigReloader: Send + Sync { async fn reload_now(&self) -> Result; } +#[async_trait] +pub trait BlockedRequestObserver: Send + Sync + 'static { + async fn on_blocked_request(&self, request: BlockedRequest); +} + +#[async_trait] +impl BlockedRequestObserver for Arc { + async fn on_blocked_request(&self, request: BlockedRequest) { + (**self).on_blocked_request(request).await + } +} + +#[async_trait] +impl BlockedRequestObserver for F +where + F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static, + Fut: Future + Send, +{ + async fn on_blocked_request(&self, request: BlockedRequest) { + (self)(request).await + } +} + pub struct NetworkProxyState { state: Arc>, reloader: Arc, + blocked_request_observer: Arc>>>, } impl std::fmt::Debug for NetworkProxyState { @@ -180,18 +205,36 @@ impl Clone for NetworkProxyState { Self { state: self.state.clone(), reloader: self.reloader.clone(), + blocked_request_observer: self.blocked_request_observer.clone(), } } } impl NetworkProxyState { pub fn with_reloader(state: ConfigState, reloader: Arc) -> Self { + Self::with_reloader_and_blocked_observer(state, reloader, None) + } + + pub fn with_reloader_and_blocked_observer( + state: ConfigState, + reloader: Arc, + blocked_request_observer: Option>, + ) -> Self { Self { state: Arc::new(RwLock::new(state)), reloader, + blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)), } } + pub async fn set_blocked_request_observer( + &self, + blocked_request_observer: Option>, + ) { + let mut observer = self.blocked_request_observer.write().await; + *observer = blocked_request_observer; + } + pub async fn current_cfg(&self) -> Result { // Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to // `config.toml` (including Codex-managed writes) take effect without a restart. @@ -312,6 +355,8 @@ impl NetworkProxyState { pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { self.reload_if_needed().await?; + let blocked_for_observer = entry.clone(); + let blocked_request_observer = self.blocked_request_observer.read().await.clone(); let violation_line = blocked_request_violation_log_line(&entry); let mut guard = self.state.write().await; let host = entry.host.clone(); @@ -340,6 +385,11 @@ impl NetworkProxyState { guard.blocked.len() ); debug!("{violation_line}"); + drop(guard); + + if let Some(observer) = blocked_request_observer { + observer.on_blocked_request(blocked_for_observer).await; + } Ok(()) } @@ -351,20 +401,6 @@ impl NetworkProxyState { Ok(guard.blocked.iter().cloned().collect()) } - pub async fn latest_blocked_for_attempt( - &self, - attempt_id: &str, - ) -> Result> { - self.reload_if_needed().await?; - let guard = self.state.read().await; - Ok(guard - .blocked - .iter() - .rev() - .find(|entry| entry.attempt_id.as_deref() == Some(attempt_id)) - .cloned()) - } - /// Drain and return the buffered blocked-request entries in FIFO order. pub async fn drain_blocked(&self) -> Result> { self.reload_if_needed().await?; @@ -692,64 +728,6 @@ mod tests { assert_eq!(drained[0].port, snapshot[0].port); } - #[tokio::test] - async fn latest_blocked_for_attempt_returns_latest_matching_entry() { - let state = network_proxy_state_for_policy(NetworkProxySettings::default()); - - state - .record_blocked(BlockedRequest::new(BlockedRequestArgs { - host: "one.example.com".to_string(), - reason: "not_allowed".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("ask".to_string()), - source: Some("decider".to_string()), - port: Some(80), - })) - .await - .expect("entry should be recorded"); - state - .record_blocked(BlockedRequest::new(BlockedRequestArgs { - host: "two.example.com".to_string(), - reason: "not_allowed".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-2".to_string()), - decision: Some("ask".to_string()), - source: Some("decider".to_string()), - port: Some(80), - })) - .await - .expect("entry should be recorded"); - state - .record_blocked(BlockedRequest::new(BlockedRequestArgs { - host: "three.example.com".to_string(), - reason: "not_allowed".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("ask".to_string()), - source: Some("decider".to_string()), - port: Some(80), - })) - .await - .expect("entry should be recorded"); - - let latest = state - .latest_blocked_for_attempt("attempt-1") - .await - .expect("lookup should succeed") - .expect("attempt should have a blocked entry"); - assert_eq!(latest.host, "three.example.com"); - } - #[tokio::test] async fn drain_blocked_returns_buffered_window() { let state = network_proxy_state_for_policy(NetworkProxySettings::default()); diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index f50eb5dfac..e3ba85eab1 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -748,6 +748,7 @@ impl OtelManager { ResponseEvent::ReasoningSummaryPartAdded { .. } => { "reasoning_summary_part_added".into() } + ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 635cd5223d..4ee7bd8b30 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -37,6 +37,24 @@ impl From> for ExecPolicyAmendment { } } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum NetworkApprovalProtocol { + // TODO(viyatb): Add websocket protocol variants when managed proxy policy + // decisions expose websocket traffic as a distinct approval context. + Http, + #[serde(alias = "https_connect", alias = "http-connect")] + Https, + Socks5Tcp, + Socks5Udp, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. @@ -52,6 +70,10 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + /// Optional network context for a blocked request that can be approved. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network_approval_context: Option, /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 2f0a024fd0..3a98aa5398 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -225,8 +225,6 @@ const APPROVAL_POLICY_UNLESS_TRUSTED: &str = include_str!("prompts/permissions/approval_policy/unless_trusted.md"); const APPROVAL_POLICY_ON_FAILURE: &str = include_str!("prompts/permissions/approval_policy/on_failure.md"); -const APPROVAL_POLICY_ON_REQUEST: &str = - include_str!("prompts/permissions/approval_policy/on_request.md"); const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("prompts/permissions/approval_policy/on_request_rule.md"); @@ -241,29 +239,20 @@ impl DeveloperInstructions { Self { text: text.into() } } - pub fn from( - approval_policy: AskForApproval, - exec_policy: &Policy, - request_rule_enabled: bool, - ) -> DeveloperInstructions { + pub fn from(approval_policy: AskForApproval, exec_policy: &Policy) -> DeveloperInstructions { let text = match approval_policy { AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(), AskForApproval::UnlessTrusted => APPROVAL_POLICY_UNLESS_TRUSTED.to_string(), AskForApproval::OnFailure => APPROVAL_POLICY_ON_FAILURE.to_string(), AskForApproval::OnRequest => { - if !request_rule_enabled { - APPROVAL_POLICY_ON_REQUEST.to_string() - } else { - let command_prefixes = - format_allow_prefixes(exec_policy.get_allowed_prefixes()); - match command_prefixes { - Some(prefixes) => { - format!( - "{APPROVAL_POLICY_ON_REQUEST_RULE}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" - ) - } - None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(), + let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes()); + match command_prefixes { + Some(prefixes) => { + format!( + "{APPROVAL_POLICY_ON_REQUEST_RULE}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" + ) } + None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(), } } }; @@ -301,7 +290,6 @@ impl DeveloperInstructions { sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, exec_policy: &Policy, - request_rule_enabled: bool, cwd: &Path, ) -> Self { let network_access = if sandbox_policy.has_full_network_access() { @@ -325,7 +313,6 @@ impl DeveloperInstructions { network_access, approval_policy, exec_policy, - request_rule_enabled, writable_roots, ) } @@ -349,7 +336,6 @@ impl DeveloperInstructions { network_access: NetworkAccess, approval_policy: AskForApproval, exec_policy: &Policy, - request_rule_enabled: bool, writable_roots: Option>, ) -> Self { let start_tag = DeveloperInstructions::new(""); @@ -359,11 +345,7 @@ impl DeveloperInstructions { sandbox_mode, network_access, )) - .concat(DeveloperInstructions::from( - approval_policy, - exec_policy, - request_rule_enabled, - )) + .concat(DeveloperInstructions::from(approval_policy, exec_policy)) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) } @@ -1207,7 +1189,6 @@ mod tests { NetworkAccess::Enabled, AskForApproval::OnRequest, &Policy::empty(), - false, None, ); @@ -1217,7 +1198,7 @@ mod tests { "expected network access to be enabled in message" ); assert!( - text.contains("`approval_policy` is `on-request`"), + text.contains("How to request escalation"), "expected approval guidance to be included" ); } @@ -1236,7 +1217,6 @@ mod tests { &policy, AskForApproval::UnlessTrusted, &Policy::empty(), - false, &PathBuf::from("/tmp"), ); let text = instructions.into_text(); @@ -1245,7 +1225,7 @@ mod tests { } #[test] - fn includes_request_rule_instructions_when_enabled() { + fn includes_request_rule_instructions_for_on_request() { let mut exec_policy = Policy::empty(); exec_policy .add_prefix_rule( @@ -1258,7 +1238,6 @@ mod tests { NetworkAccess::Enabled, AskForApproval::OnRequest, &exec_policy, - true, None, ); @@ -1617,10 +1596,14 @@ mod tests { let image_url = "data:image/png;base64,abc".to_string(); let dir = tempdir()?; let local_path = dir.path().join("local.png"); - let png_bytes = include_bytes!( - "../../core/src/skills/assets/samples/skill-creator/assets/skill-creator.png" - ); - std::fs::write(&local_path, png_bytes.as_slice())?; + // A tiny valid PNG (1x1) so this test doesn't depend on cross-crate file paths, which + // break under Bazel sandboxing. + const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, + 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, + 0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + std::fs::write(&local_path, TINY_PNG_BYTES)?; let item = ResponseInputItem::from(vec![ UserInput::Image { diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index d9275e3830..e61860698a 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -253,6 +253,11 @@ pub struct ModelInfo { /// When true, this model should use websocket transport even when websocket features are off. #[serde(default)] pub prefer_websockets: bool, + /// Internal-only marker set by core when a model slug resolved to fallback metadata. + #[serde(default, skip_serializing, skip_deserializing)] + #[schemars(skip)] + #[ts(skip)] + pub used_fallback_model_metadata: bool, } impl ModelInfo { @@ -517,6 +522,7 @@ mod tests { experimental_supported_tools: vec![], input_modalities: default_input_modalities(), prefer_websockets: false, + used_fallback_model_metadata: false, } } diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md deleted file mode 100644 index 16c90e8012..0000000000 --- a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md +++ /dev/null @@ -1,12 +0,0 @@ - Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-request`: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. - -Here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter \ No newline at end of file diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e44bf4c1d8..f9c56c5bc4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -54,6 +54,8 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; +pub use crate::approvals::NetworkApprovalContext; +pub use crate::approvals::NetworkApprovalProtocol; pub use crate::request_user_input::RequestUserInputEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid @@ -283,13 +285,14 @@ pub enum Op { }, /// Request the list of remote skills available via ChatGPT sharing. - ListRemoteSkills, + ListRemoteSkills { + hazelnut_scope: RemoteSkillHazelnutScope, + product_surface: RemoteSkillProductSurface, + enabled: Option, + }, /// Download a remote skill by id into the local skills cache. - DownloadRemoteSkill { - hazelnut_id: String, - is_preload: bool, - }, + DownloadRemoteSkill { hazelnut_id: String }, /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) @@ -881,6 +884,9 @@ pub enum EventMsg { /// indicates the turn continued but the user should still be notified. Warning(WarningEvent), + /// Model routing changed from the requested model to a different model. + ModelReroute(ModelRerouteEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1340,6 +1346,20 @@ pub struct WarningEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ModelRerouteReason { + HighRiskCyberActivity, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct ModelRerouteEvent { + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; @@ -2403,6 +2423,26 @@ pub struct RemoteSkillSummary { pub description: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case")] +pub enum RemoteSkillHazelnutScope { + WorkspaceShared, + AllShared, + Personal, + Example, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +pub enum RemoteSkillProductSurface { + Chatgpt, + Codex, + Api, + Atlas, +} + /// Response payload for `Op::ListRemoteSkills`. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ListRemoteSkillsResponseEvent { diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 47573621a8..770ff4795b 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -128,9 +128,6 @@ pub fn parse_shell_lc_single_command_prefix(command: &[String]) -> Option) -> bool { node.named_children(&mut cursor).next().is_none() } -fn has_named_descendant_kind(node: Node<'_>, kind: &str) -> bool { - let mut stack = vec![node]; - while let Some(current) = stack.pop() { - if current.kind() == kind { - return true; - } - let mut cursor = current.walk(); - for child in current.named_children(&mut cursor) { - stack.push(child); - } - } - false -} - fn is_allowed_heredoc_attachment_kind(kind: &str) -> bool { matches!( kind, @@ -532,7 +515,10 @@ mod tests { "-lc".to_string(), "echo hello > /tmp/out.txt".to_string(), ]; - assert_eq!(parse_shell_lc_single_command_prefix(&command), None); + assert_eq!( + parse_shell_lc_single_command_prefix(&command), + Some(vec!["echo".to_string(), "hello".to_string()]) + ); } #[test] @@ -548,6 +534,16 @@ mod tests { ); } + #[test] + fn parse_shell_lc_single_command_prefix_rejects_herestring_with_chaining() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + r#"echo hello > /tmp/out.txt && cat /tmp/out.txt"#.to_string(), + ]; + assert_eq!(parse_shell_lc_single_command_prefix(&command), None); + } + #[test] fn parse_shell_lc_single_command_prefix_rejects_herestring_with_substitution() { let command = vec![ diff --git a/codex-rs/state/migrations/0010_logs_process_id.sql b/codex-rs/state/migrations/0010_logs_process_id.sql new file mode 100644 index 0000000000..fdefcd53a8 --- /dev/null +++ b/codex-rs/state/migrations/0010_logs_process_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE logs ADD COLUMN process_uuid TEXT; + +CREATE INDEX idx_logs_process_uuid ON logs(process_uuid); diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index 796a968cb8..6b0039ee97 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -46,6 +46,10 @@ struct Args { #[arg(long = "thread-id")] thread_id: Vec, + /// Substring match against the log message. + #[arg(long)] + search: Option, + /// Include logs that do not have a thread id. #[arg(long)] threadless: bool, @@ -57,6 +61,10 @@ struct Args { /// Poll interval in milliseconds. #[arg(long, default_value_t = 500)] poll_ms: u64, + + /// Show compact output with only time, level, and message. + #[arg(long)] + compact: bool, } #[derive(Debug, Clone)] @@ -67,6 +75,7 @@ struct LogFilter { module_like: Vec, file_like: Vec, thread_ids: Vec, + search: Option, include_threadless: bool, } @@ -81,7 +90,8 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_else(|| PathBuf::from(".")); let runtime = StateRuntime::init(codex_home, "logs-client".to_string(), None).await?; - let mut last_id = print_backfill(runtime.as_ref(), &filter, args.backfill).await?; + let mut last_id = + print_backfill(runtime.as_ref(), &filter, args.backfill, args.compact).await?; if last_id == 0 { last_id = fetch_max_id(runtime.as_ref(), &filter).await?; } @@ -91,7 +101,7 @@ async fn main() -> anyhow::Result<()> { let rows = fetch_new_rows(runtime.as_ref(), &filter, last_id).await?; for row in rows { last_id = last_id.max(row.id); - println!("{}", format_row(&row)); + println!("{}", format_row(&row, args.compact)); } tokio::time::sleep(poll_interval).await; } @@ -154,6 +164,7 @@ fn build_filter(args: &Args) -> anyhow::Result { module_like, file_like, thread_ids, + search: args.search.clone(), include_threadless: args.threadless, }) } @@ -172,6 +183,7 @@ async fn print_backfill( runtime: &StateRuntime, filter: &LogFilter, backfill: usize, + compact: bool, ) -> anyhow::Result { if backfill == 0 { return Ok(0); @@ -183,7 +195,7 @@ async fn print_backfill( let mut last_id = 0; for row in rows { last_id = last_id.max(row.id); - println!("{}", format_row(&row)); + println!("{}", format_row(&row, compact)); } Ok(last_id) } @@ -233,6 +245,7 @@ fn to_log_query( module_like: filter.module_like.clone(), file_like: filter.file_like.clone(), thread_ids: filter.thread_ids.clone(), + search: filter.search.clone(), include_threadless: filter.include_threadless, after_id, limit, @@ -240,8 +253,8 @@ fn to_log_query( } } -fn format_row(row: &LogRow) -> String { - let timestamp = formatter::ts(row.ts, row.ts_nanos); +fn format_row(row: &LogRow, compact: bool) -> String { + let timestamp = formatter::ts(row.ts, row.ts_nanos, compact); let level = row.level.as_str(); let target = row.target.as_str(); let message = row.message.as_deref().unwrap_or(""); @@ -251,9 +264,13 @@ fn format_row(row: &LogRow) -> String { let thread_id_colored = thread_id.blue().dimmed().to_string(); let target_colored = target.dimmed().to_string(); let message_colored = heuristic_formatting(message); - format!( - "{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}" - ) + if compact { + format!("{timestamp_colored} {level_colored} {message_colored}") + } else { + format!( + "{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}" + ) + } } fn heuristic_formatting(message: &str) -> String { @@ -292,9 +309,10 @@ mod formatter { .join("\n") } - pub(super) fn ts(ts: i64, ts_nanos: i64) -> String { + pub(super) fn ts(ts: i64, ts_nanos: i64, compact: bool) -> String { let nanos = u32::try_from(ts_nanos).unwrap_or(0); match DateTime::::from_timestamp(ts, nanos) { + Some(dt) if compact => dt.format("%H:%M:%S").to_string(), Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true), None => format!("{ts}.{ts_nanos:09}Z"), } diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 345e90c6a8..64deeef2ad 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -20,6 +20,7 @@ use chrono::Duration as ChronoDuration; use chrono::Utc; +use std::sync::OnceLock; use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -33,6 +34,7 @@ use tracing::span::Id; use tracing::span::Record; use tracing_subscriber::Layer; use tracing_subscriber::registry::LookupSpan; +use uuid::Uuid; use crate::LogEntry; use crate::StateRuntime; @@ -44,14 +46,19 @@ const LOG_RETENTION_DAYS: i64 = 90; pub struct LogDbLayer { sender: mpsc::Sender, + process_uuid: String, } pub fn start(state_db: std::sync::Arc) -> LogDbLayer { + let process_uuid = current_process_log_uuid().to_string(); let (sender, receiver) = mpsc::channel(LOG_QUEUE_CAPACITY); tokio::spawn(run_inserter(std::sync::Arc::clone(&state_db), receiver)); tokio::spawn(run_retention_cleanup(state_db)); - LogDbLayer { sender } + LogDbLayer { + sender, + process_uuid, + } } impl Layer for LogDbLayer @@ -118,6 +125,7 @@ where target: metadata.target().to_string(), message: visitor.message, thread_id, + process_uuid: Some(self.process_uuid.clone()), module_path: metadata.module_path().map(ToString::to_string), file: metadata.file().map(ToString::to_string), line: metadata.line().map(|line| line as i64), @@ -196,6 +204,15 @@ where thread_id } +fn current_process_log_uuid() -> &'static str { + static PROCESS_LOG_UUID: OnceLock = OnceLock::new(); + PROCESS_LOG_UUID.get_or_init(|| { + let pid = std::process::id(); + let process_uuid = Uuid::new_v4(); + format!("pid:{pid}:{process_uuid}") + }) +} + async fn run_inserter( state_db: std::sync::Arc, mut receiver: mpsc::Receiver, diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index 819abb5d22..cf973bceeb 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -9,6 +9,7 @@ pub struct LogEntry { pub target: String, pub message: Option, pub thread_id: Option, + pub process_uuid: Option, pub module_path: Option, pub file: Option, pub line: Option, @@ -23,6 +24,7 @@ pub struct LogRow { pub target: String, pub message: Option, pub thread_id: Option, + pub process_uuid: Option, pub file: Option, pub line: Option, } @@ -35,6 +37,7 @@ pub struct LogQuery { pub module_like: Vec, pub file_like: Vec, pub thread_ids: Vec, + pub search: Option, pub include_threadless: bool, pub after_id: Option, pub limit: Option, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 4cc9c63b17..f9beacdae8 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -360,7 +360,7 @@ FROM threads } let mut builder = QueryBuilder::::new( - "INSERT INTO logs (ts, ts_nanos, level, target, message, thread_id, module_path, file, line) ", + "INSERT INTO logs (ts, ts_nanos, level, target, message, thread_id, process_uuid, module_path, file, line) ", ); builder.push_values(entries, |mut row, entry| { row.push_bind(entry.ts) @@ -369,6 +369,7 @@ FROM threads .push_bind(&entry.target) .push_bind(&entry.message) .push_bind(&entry.thread_id) + .push_bind(&entry.process_uuid) .push_bind(&entry.module_path) .push_bind(&entry.file) .push_bind(entry.line); @@ -388,7 +389,7 @@ FROM threads /// Query logs with optional filters. pub async fn query_logs(&self, query: &LogQuery) -> anyhow::Result> { let mut builder = QueryBuilder::::new( - "SELECT id, ts, ts_nanos, level, target, message, thread_id, file, line FROM logs WHERE 1 = 1", + "SELECT id, ts, ts_nanos, level, target, message, thread_id, process_uuid, file, line FROM logs WHERE 1 = 1", ); push_log_filters(&mut builder, query); if query.descending { @@ -710,6 +711,11 @@ fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQu if let Some(after_id) = query.after_id { builder.push(" AND id > ").push_bind(after_id); } + if let Some(search) = query.search.as_ref() { + builder.push(" AND INSTR(message, "); + builder.push_bind(search.as_str()); + builder.push(") > 0"); + } } fn push_like_filters<'a>( @@ -905,6 +911,8 @@ mod tests { use super::StateRuntime; use super::ThreadMetadata; use super::state_db_filename; + use crate::LogEntry; + use crate::LogQuery; use crate::STATE_DB_FILENAME; use crate::STATE_DB_VERSION; use crate::model::Phase2JobClaimOutcome; @@ -2494,6 +2502,57 @@ VALUES (?, ?, ?, ?, ?) let _ = tokio::fs::remove_dir_all(codex_home).await; } + #[tokio::test] + async fn query_logs_with_search_matches_substring() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1_700_000_001, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("alpha".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: None, + file: Some("main.rs".to_string()), + line: Some(42), + module_path: None, + }, + LogEntry { + ts: 1_700_000_002, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("alphabet".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: None, + file: Some("main.rs".to_string()), + line: Some(43), + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let rows = runtime + .query_logs(&LogQuery { + search: Some("alphab".to_string()), + ..Default::default() + }) + .await + .expect("query matching logs"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].message.as_deref(), Some("alphabet")); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + fn test_thread_metadata( codex_home: &Path, thread_id: ThreadId, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7bd105695d..108f85ff8a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -978,6 +978,27 @@ impl App { self.refresh_status_line(); } + fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool { + matches!( + session_selection, + SessionSelection::StartFresh | SessionSelection::Exit + ) + } + + fn should_handle_active_thread_events( + waiting_for_initial_session_configured: bool, + has_active_thread_receiver: bool, + ) -> bool { + has_active_thread_receiver && !waiting_for_initial_session_configured + } + + fn should_stop_waiting_for_initial_session( + waiting_for_initial_session_configured: bool, + primary_thread_id: Option, + ) -> bool { + waiting_for_initial_session_configured && primary_thread_id.is_some() + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -1008,11 +1029,11 @@ impl App { )); let mut model = thread_manager .get_models_manager() - .get_default_model(&config.model, &config, RefreshStrategy::Offline) + .get_default_model(&config.model, RefreshStrategy::Offline) .await; let available_models = thread_manager .get_models_manager() - .list_models(&config, RefreshStrategy::Offline) + .list_models(RefreshStrategy::Offline) .await; let exit_info = handle_model_migration_prompt_if_needed( tui, @@ -1068,6 +1089,8 @@ impl App { let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let enhanced_keys_supported = tui.enhanced_keys_supported(); + let wait_for_initial_session_configured = + Self::should_wait_for_initial_session(&session_selection); let mut chat_widget = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -1252,6 +1275,7 @@ impl App { let mut thread_created_rx = thread_manager.subscribe_thread_created(); let mut listen_for_threads = true; + let mut waiting_for_initial_session_configured = wait_for_initial_session_configured; let exit_reason = loop { let control = select! { @@ -1264,7 +1288,10 @@ impl App { } else { None } - }, if app.active_thread_rx.is_some() => { + }, if App::should_handle_active_thread_events( + waiting_for_initial_session_configured, + app.active_thread_rx.is_some() + ) => { if let Some(event) = active { app.handle_active_thread_event(tui, event).await?; } else { @@ -1291,6 +1318,12 @@ impl App { AppRunControl::Continue } }; + if App::should_stop_waiting_for_initial_session( + waiting_for_initial_session_configured, + app.primary_thread_id, + ) { + waiting_for_initial_session_configured = false; + } match control { AppRunControl::Continue => {} AppRunControl::Exit(reason) => break reason, @@ -2915,6 +2948,76 @@ mod tests { Ok(()) } + #[test] + fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::StartFresh), + true + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Exit), + true + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Resume(PathBuf::from( + "/tmp/restore" + ))), + false + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Fork(PathBuf::from( + "/tmp/fork" + ))), + false + ); + } + + #[test] + fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() { + let mut wait_for_initial_session = + App::should_wait_for_initial_session(&SessionSelection::StartFresh); + assert_eq!(wait_for_initial_session, true); + assert_eq!( + App::should_handle_active_thread_events(wait_for_initial_session, true), + false + ); + + assert_eq!( + App::should_stop_waiting_for_initial_session(wait_for_initial_session, None), + false + ); + if App::should_stop_waiting_for_initial_session( + wait_for_initial_session, + Some(ThreadId::new()), + ) { + wait_for_initial_session = false; + } + assert_eq!(wait_for_initial_session, false); + + assert_eq!( + App::should_handle_active_thread_events(wait_for_initial_session, true), + true + ); + } + + #[test] + fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { + let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume( + PathBuf::from("/tmp/restore"), + )); + assert_eq!( + App::should_handle_active_thread_events(wait_for_resume, true), + true + ); + let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork( + PathBuf::from("/tmp/fork"), + )); + assert_eq!( + App::should_handle_active_thread_events(wait_for_fork, true), + true + ); + } + #[tokio::test] async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8bf3ab3703..1c9e8ce88e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -371,5 +371,6 @@ pub(crate) enum FeedbackCategory { BadResult, GoodResult, Bug, + SafetyCheck, Other, } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 42abe174d4..567f18f332 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -20,6 +20,7 @@ use codex_core::features::Features; use codex_core::protocol::ElicitationAction; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; +use codex_core::protocol::NetworkApprovalContext; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; use codex_protocol::mcp::RequestId; @@ -42,6 +43,7 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, + network_approval_context: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -108,11 +110,23 @@ impl ApprovalOverlay { ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { ApprovalVariant::Exec { + network_approval_context, proposed_execpolicy_amendment, .. } => ( - exec_options(proposed_execpolicy_amendment.clone()), - "Would you like to run the following command?".to_string(), + exec_options( + proposed_execpolicy_amendment.clone(), + network_approval_context.as_ref(), + ), + network_approval_context.as_ref().map_or_else( + || "Would you like to run the following command?".to_string(), + |network_approval_context| { + format!( + "Do you want to approve access to \"{}\"?", + network_approval_context.host + ) + }, + ), ), ApprovalVariant::ApplyPatch { .. } => ( patch_options(), @@ -342,6 +356,7 @@ impl From for ApprovalRequestState { id, command, reason, + network_approval_context, proposed_execpolicy_amendment, } => { let mut header: Vec> = Vec::new(); @@ -359,6 +374,7 @@ impl From for ApprovalRequestState { variant: ApprovalVariant::Exec { id, command, + network_approval_context, proposed_execpolicy_amendment, }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), @@ -414,6 +430,7 @@ enum ApprovalVariant { Exec { id: String, command: Vec, + network_approval_context: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -447,7 +464,33 @@ impl ApprovalOption { } } -fn exec_options(proposed_execpolicy_amendment: Option) -> Vec { +fn exec_options( + proposed_execpolicy_amendment: Option, + network_approval_context: Option<&NetworkApprovalContext>, +) -> Vec { + if network_approval_context.is_some() { + return vec![ + ApprovalOption { + label: "Yes, just this once".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "Yes, and allow this host for this session".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + }, + ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ]; + } + vec![ApprovalOption { label: "Yes, proceed".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), @@ -531,6 +574,7 @@ fn elicitation_options() -> Vec { mod tests { use super::*; use crate::app_event::AppEvent; + use codex_core::protocol::NetworkApprovalProtocol; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -539,6 +583,7 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), + network_approval_context: None, proposed_execpolicy_amendment: None, } } @@ -581,6 +626,7 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -619,6 +665,7 @@ mod tests { id: "test".into(), command, reason: None, + network_approval_context: None, proposed_execpolicy_amendment: None, }; @@ -641,6 +688,67 @@ mod tests { ); } + #[test] + fn network_exec_options_use_expected_labels_and_hide_execpolicy_amendment() { + let network_context = NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + let options = exec_options( + Some(ExecPolicyAmendment::new(vec!["curl".to_string()])), + Some(&network_context), + ); + + let labels: Vec = options.into_iter().map(|option| option.label).collect(); + assert_eq!( + labels, + vec![ + "Yes, just this once".to_string(), + "Yes, and allow this host for this session".to_string(), + "No, and tell Codex what to do differently".to_string(), + ] + ); + } + + #[test] + fn network_exec_prompt_title_includes_host() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec { + id: "test".into(), + command: vec!["curl".into(), "https://example.com".into()], + reason: Some("network request blocked".into()), + network_approval_context: Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])), + }; + + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100))); + view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + + assert!( + rendered + .iter() + .any(|line| line.contains("Do you want to approve access to \"example.com\"?")), + "expected network title to include host, got {rendered:?}" + ); + assert!( + !rendered.iter().any(|line| line.contains("don't ask again")), + "network prompt should not show execpolicy option, got {rendered:?}" + ); + } + #[test] fn exec_history_cell_wraps_with_two_space_indent() { let command = vec![ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 36142cbaed..91dfa00a52 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4596,6 +4596,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4633,6 +4636,9 @@ mod tests { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 48cf255ee0..1c2291465e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -45,12 +45,15 @@ pub(crate) struct CommandPopupFlags { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { // Keep built-in availability in sync with the composer. - let builtins = slash_commands::builtins_for_input( + let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input( flags.collaboration_modes_enabled, flags.connectors_enabled, flags.personality_command_enabled, flags.windows_degraded_sandbox_active, - ); + ) + .into_iter() + .filter(|(name, _)| !name.starts_with("debug")) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); @@ -566,4 +569,22 @@ mod tests { other => panic!("expected personality to be selected for exact match, got {other:?}"), } } + + #[test] + fn debug_commands_are_hidden_from_popup() { + let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + + assert!( + !cmds.iter().any(|name| name.starts_with("debug")), + "expected no /debug* command in popup menu, got {cmds:?}" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 6bc0ab4474..6cbfbdb9e5 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -353,6 +353,10 @@ fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String "Tell us more (bug)".to_string(), "(optional) Write a short description to help us further".to_string(), ), + FeedbackCategory::SafetyCheck => ( + "Tell us more (safety check)".to_string(), + "(optional) Share what was refused and why it should have been allowed".to_string(), + ), FeedbackCategory::Other => ( "Tell us more (other)".to_string(), "(optional) Write a short description to help us further".to_string(), @@ -365,6 +369,7 @@ fn feedback_classification(category: FeedbackCategory) -> &'static str { FeedbackCategory::BadResult => "bad_result", FeedbackCategory::GoodResult => "good_result", FeedbackCategory::Bug => "bug", + FeedbackCategory::SafetyCheck => "safety_check", FeedbackCategory::Other => "other", } } @@ -378,14 +383,15 @@ fn issue_url_for_category( // the external GitHub behavior identical while routing internal users to // the internal go link. match category { - FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => { - Some(match feedback_audience { - FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id), - FeedbackAudience::External => { - format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}") - } - }) - } + FeedbackCategory::Bug + | FeedbackCategory::BadResult + | FeedbackCategory::SafetyCheck + | FeedbackCategory::Other => Some(match feedback_audience { + FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id), + FeedbackAudience::External => { + format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}") + } + }), FeedbackCategory::GoodResult => None, } } @@ -423,6 +429,12 @@ pub(crate) fn feedback_selection_params( "Helpful, correct, high‑quality, or delightful result worth celebrating.", FeedbackCategory::GoodResult, ), + make_feedback_item( + app_event_tx.clone(), + "safety check", + "Benign usage blocked due to safety checks or refusals.", + FeedbackCategory::SafetyCheck, + ), make_feedback_item( app_event_tx, "other", @@ -616,7 +628,14 @@ mod tests { } #[test] - fn issue_url_available_for_bug_bad_result_and_other() { + fn feedback_view_safety_check() { + let view = make_view(FeedbackCategory::SafetyCheck); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_safety_check", rendered); + } + + #[test] + fn issue_url_available_for_bug_bad_result_safety_check_and_other() { let bug_url = issue_url_for_category( FeedbackCategory::Bug, "thread-1", @@ -639,6 +658,13 @@ mod tests { ); assert!(other_url.is_some()); + let safety_check_url = issue_url_for_category( + FeedbackCategory::SafetyCheck, + "thread-4", + FeedbackAudience::OpenAiEmployee, + ); + assert!(safety_check_url.is_some()); + assert!( issue_url_for_category( FeedbackCategory::GoodResult, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 4e4dabe5c6..43aecb4940 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1006,6 +1006,7 @@ mod tests { id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, + network_approval_context: None, proposed_execpolicy_amendment: None, } } @@ -1402,6 +1403,7 @@ mod tests { interface: None, dependencies: None, policy: None, + permissions: None, path: PathBuf::from("test-skill"), scope: SkillScope::User, }]), diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index cdd0ba8f7d..4613161f2f 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -63,3 +63,15 @@ pub(crate) fn has_builtin_prefix( .into_iter() .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn debug_command_still_resolves_for_dispatch() { + let cmd = find_builtin_command("debug-config", true, true, true, false); + assert_eq!(cmd, Some(SlashCommand::DebugConfig)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_safety_check.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_safety_check.snap new file mode 100644 index 0000000000..52148d0e86 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_safety_check.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (safety check) +▌ +▌ (optional) Share what was refused and why it should have b + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 36251c4482..0a6da6a5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -184,7 +184,6 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; -use crate::collab; use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -201,6 +200,7 @@ use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::markdown::append_markdown; +use crate::multi_agents; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::FlexRenderable; @@ -2394,6 +2394,7 @@ impl ChatWidget { id: ev.call_id, command: ev.command, reason: ev.reason, + network_approval_context: ev.network_approval_context, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane @@ -4020,6 +4021,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { message, codex_error_info, @@ -4112,17 +4114,19 @@ impl ChatWidget { EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), EventMsg::CollabAgentSpawnBegin(_) => {} - EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)), + EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(multi_agents::spawn_end(ev)), EventMsg::CollabAgentInteractionBegin(_) => {} EventMsg::CollabAgentInteractionEnd(ev) => { - self.on_collab_event(collab::interaction_end(ev)) + self.on_collab_event(multi_agents::interaction_end(ev)) } - EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)), - EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)), + EventMsg::CollabWaitingBegin(ev) => { + self.on_collab_event(multi_agents::waiting_begin(ev)) + } + EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(multi_agents::waiting_end(ev)), EventMsg::CollabCloseBegin(_) => {} - EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)), - EventMsg::CollabResumeBegin(ev) => self.on_collab_event(collab::resume_begin(ev)), - EventMsg::CollabResumeEnd(ev) => self.on_collab_event(collab::resume_end(ev)), + EventMsg::CollabCloseEnd(ev) => self.on_collab_event(multi_agents::close_end(ev)), + EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), + EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), EventMsg::ThreadRolledBack(rollback) => { if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { @@ -4695,7 +4699,7 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.try_list_models(&self.config).ok()?; + let models = self.models_manager.try_list_models().ok()?; models .iter() .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) @@ -4812,7 +4816,7 @@ impl ChatWidget { return; } - let presets: Vec = match self.models_manager.try_list_models(&self.config) { + let presets: Vec = match self.models_manager.try_list_models() { Ok(models) => models, Err(_) => { self.add_info_message( @@ -5422,15 +5426,23 @@ impl ChatWidget { }); })] } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + name.clone(), + ) } } #[cfg(not(target_os = "windows"))] { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + name.clone(), + ) } } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions(preset.approval, preset.sandbox.clone(), name.clone()) }; items.push(SelectionItem { name, @@ -5484,6 +5496,7 @@ impl ChatWidget { fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, + label: String, ) -> Vec { vec![Box::new(move |tx| { let sandbox_clone = sandbox.clone(); @@ -5500,6 +5513,9 @@ impl ChatWidget { })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(format!("Permissions updated to {label}"), None), + ))); })] } @@ -5546,6 +5562,7 @@ impl ChatWidget { preset: ApprovalPreset, return_to_permissions: bool, ) { + let selected_name = preset.label.to_string(); let approval = preset.approval; let sandbox = preset.sandbox; let mut header_children: Vec> = Vec::new(); @@ -5562,12 +5579,14 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + let mut accept_actions = + Self::approval_preset_actions(approval, sandbox.clone(), selected_name.clone()); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); - let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + let mut accept_and_remember_actions = + Self::approval_preset_actions(approval, sandbox, selected_name); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); tx.send(AppEvent::PersistFullAccessWarningAcknowledged); @@ -5677,7 +5696,11 @@ impl ChatWidget { })); } if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + mode_label.to_string(), + )); } let mut accept_and_remember_actions: Vec = Vec::new(); @@ -5686,7 +5709,11 @@ impl ChatWidget { tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_and_remember_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + mode_label.to_string(), + )); } let items = vec![ @@ -6111,7 +6138,7 @@ impl ChatWidget { fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.models_manager - .try_list_models(&self.config) + .try_list_models() .ok() .and_then(|models| { models @@ -6129,7 +6156,7 @@ impl ChatWidget { fn current_model_supports_images(&self) -> bool { let model = self.current_model(); self.models_manager - .try_list_models(&self.config) + .try_list_models() .ok() .and_then(|models| { models diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 6ec4aefa6d..e7fadde1af 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -190,6 +190,7 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { .collect(), }), policy: None, + permissions: None, path: skill.path.clone(), scope: skill.scope, } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap index 4a98242027..1b7627a97e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap @@ -4,8 +4,10 @@ expression: popup --- How was this? -› 1. bug Crash, error message, hang, or broken UI/behavior. - 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. - 3. good result Helpful, correct, high‑quality, or delightful result worth - celebrating. - 4. other Slowness, feature suggestion, UX feedback, or anything else. +› 1. bug Crash, error message, hang, or broken UI/behavior. + 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. + 3. good result Helpful, correct, high‑quality, or delightful result worth + celebrating. + 4. safety check Benign usage blocked due to safety checks or refusals. + 5. other Slowness, feature suggestion, UX feedback, or anything + else. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_after_mode_switch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_after_mode_switch.snap new file mode 100644 index 0000000000..9cc7afa1f5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_after_mode_switch.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines_to_single_string(&cells[0])" +--- +• Permissions updated to Full Access diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap new file mode 100644 index 0000000000..135e5b1bfa --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines_to_single_string(&cells[0])" +--- +• Permissions updated to Default diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap new file mode 100644 index 0000000000..eb08108564 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines_to_single_string(&cells[0])" +--- +• Permissions updated to Default (non-admin sandbox) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cd03b9899b..bfae9581b2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -20,6 +20,8 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; use codex_core::config::ConstraintError; +#[cfg(target_os = "windows")] +use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::RequirementSource; use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; @@ -874,6 +876,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { interface: None, dependencies: None, policy: None, + permissions: None, path: repo_skill_path, scope: SkillScope::Repo, }, @@ -884,6 +887,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { interface: None, dependencies: None, policy: None, + permissions: None, path: user_skill_path.clone(), scope: SkillScope::User, }, @@ -2474,6 +2478,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2518,6 +2523,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2568,6 +2574,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + network_approval_context: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2742,7 +2749,7 @@ fn active_blob(chat: &ChatWidget) -> String { fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { let models = chat .models_manager - .try_list_models(&chat.config) + .try_list_models() .expect("models lock available"); models .iter() @@ -4417,6 +4424,9 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4450,6 +4460,9 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4461,6 +4474,9 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: true, is_enabled: true, @@ -4497,6 +4513,9 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4508,6 +4527,9 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, @@ -4529,6 +4551,9 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4565,6 +4590,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4576,6 +4604,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, @@ -4599,6 +4630,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4610,6 +4644,9 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/hidden-openai".to_string()), is_accessible: true, is_enabled: true, @@ -4650,6 +4687,9 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, @@ -4697,6 +4737,9 @@ async fn apps_initial_load_applies_enabled_state_from_config() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4731,6 +4774,9 @@ async fn apps_refresh_preserves_toggled_enabled_state() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4749,6 +4795,9 @@ async fn apps_refresh_preserves_toggled_enabled_state() { logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, @@ -4790,6 +4839,9 @@ async fn apps_popup_for_not_installed_app_uses_install_only_selected_description logo_url: None, logo_url_dark: None, distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, @@ -5425,6 +5477,196 @@ async fn approvals_popup_navigation_skips_disabled() { ); } +#[tokio::test] +async fn permissions_selection_emits_history_cell_when_selection_changes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected one permissions selection history cell" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Permissions updated to"), + "expected permissions selection history message, got: {rendered}" + ); +} + +#[tokio::test] +async fn permissions_selection_history_snapshot_after_mode_switch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + #[cfg(target_os = "windows")] + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); + assert_snapshot!( + "permissions_selection_history_after_mode_switch", + lines_to_single_string(&cells[0]) + ); +} + +#[tokio::test] +async fn permissions_selection_history_snapshot_full_access_to_default() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + chat.config + .permissions + .approval_policy + .set(AskForApproval::Never) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("set sandbox policy"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); + let rendered = lines_to_single_string(&cells[0]); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("permissions_selection_history_full_access_to_default", rendered); + }); + #[cfg(not(target_os = "windows"))] + assert_snapshot!( + "permissions_selection_history_full_access_to_default", + rendered + ); +} + +#[tokio::test] +async fn permissions_selection_emits_history_cell_when_current_is_selected() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected history cell even when selecting current permissions" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Permissions updated to"), + "expected permissions update history message, got: {rendered}" + ); +} + +#[tokio::test] +async fn permissions_full_access_history_cell_emitted_only_after_confirmation() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = None; + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + #[cfg(target_os = "windows")] + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let mut open_confirmation_event = None; + let mut cells_before_confirmation = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + cells_before_confirmation.push(cell.display_lines(80)); + } + AppEvent::OpenFullAccessConfirmation { + preset, + return_to_permissions, + } => { + open_confirmation_event = Some((preset, return_to_permissions)); + } + _ => {} + } + } + if cfg!(not(target_os = "windows")) { + assert!( + cells_before_confirmation.is_empty(), + "did not expect history cell before confirming full access" + ); + } + let (preset, return_to_permissions) = + open_confirmation_event.expect("expected full access confirmation event"); + chat.open_full_access_confirmation(preset, return_to_permissions); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Enable full access?"), + "expected full access confirmation popup, got: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let cells_after_confirmation = drain_insert_history(&mut rx); + let total_history_cells = cells_before_confirmation.len() + cells_after_confirmation.len(); + assert_eq!( + total_history_cells, 1, + "expected one full access history cell total" + ); + let rendered = if !cells_before_confirmation.is_empty() { + lines_to_single_string(&cells_before_confirmation[0]) + } else { + lines_to_single_string(&cells_after_confirmation[0]) + }; + assert!( + rendered.contains("Permissions updated to Full Access"), + "expected full access update history message, got: {rendered}" + ); +} + // // Snapshot test: command approval modal // @@ -5448,6 +5690,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -5504,6 +5747,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -5551,6 +5795,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() command: command.clone(), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), parsed_cmd: vec![], }; @@ -5910,6 +6155,7 @@ async fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + network_approval_context: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello world".into(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 639916a79f..cb4e311576 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -64,7 +64,6 @@ mod bottom_pane; mod chatwidget; mod cli; mod clipboard_paste; -mod collab; mod collaboration_modes; mod color; pub mod custom_terminal; @@ -86,6 +85,7 @@ mod markdown_render; mod markdown_stream; mod mention_codec; mod model_migration; +mod multi_agents; mod notifications; pub mod onboarding; mod oss_selection; diff --git a/codex-rs/tui/src/collab.rs b/codex-rs/tui/src/multi_agents.rs similarity index 100% rename from codex-rs/tui/src/collab.rs rename to codex-rs/tui/src/multi_agents.rs diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 94eb038cef..b41a2fa3ca 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -218,9 +218,11 @@ impl Renderable for StatusIndicatorWidget { return; } - // Schedule next animation frame. - self.frame_requester - .schedule_frame_in(Duration::from_millis(32)); + if self.animations_enabled { + // Schedule next animation frame. + self.frame_requester + .schedule_frame_in(Duration::from_millis(32)); + } let now = Instant::now(); let elapsed_duration = self.elapsed_duration_at(now); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 22f6b37546..88f9de01cd 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -6,6 +6,7 @@ Use /permissions to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. +Use /statusline to configure which items appear in the status line. Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index d2a2f94915..b40532cda8 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -52,9 +52,11 @@ pub fn compute_allow_paths( let canonical = canonicalize(&candidate).unwrap_or(candidate); add_allow(canonical.clone()); - let git_entry = canonical.join(".git"); - if git_entry.exists() { - add_deny(git_entry); + for protected_subdir in [".git", ".codex", ".agents"] { + let protected_entry = canonical.join(protected_subdir); + if protected_entry.exists() { + add_deny(protected_entry); + } } }; @@ -210,7 +212,39 @@ mod tests { } #[test] - fn skips_git_dir_when_missing() { + fn denies_codex_and_agents_inside_writable_root() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let codex_dir = command_cwd.join(".codex"); + let agents_dir = command_cwd.join(".agents"); + let _ = fs::create_dir_all(&codex_dir); + let _ = fs::create_dir_all(&agents_dir); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [ + dunce::canonicalize(&codex_dir).unwrap(), + dunce::canonicalize(&agents_dir).unwrap(), + ] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert_eq!(expected_deny, paths.deny); + } + + #[test] + fn skips_protected_subdirs_when_missing() { let tmp = TempDir::new().expect("tempdir"); let command_cwd = tmp.path().join("workspace"); let _ = fs::create_dir_all(&command_cwd); @@ -225,6 +259,6 @@ mod tests { let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); assert_eq!(paths.allow.len(), 1); - assert!(paths.deny.is_empty(), "no deny when .git is absent"); + assert!(paths.deny.is_empty(), "no deny when protected dirs are absent"); } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 61077342dc..fc96e5186b 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -127,6 +127,8 @@ pub use winutil::to_wide; #[cfg(target_os = "windows")] pub use workspace_acl::is_command_cwd_root; #[cfg(target_os = "windows")] +pub use workspace_acl::protect_workspace_agents_dir; +#[cfg(target_os = "windows")] pub use workspace_acl::protect_workspace_codex_dir; #[cfg(not(target_os = "windows"))] @@ -165,6 +167,7 @@ mod windows_impl { use super::winutil::quote_windows_arg; use super::winutil::to_wide; use super::workspace_acl::is_command_cwd_root; + use super::workspace_acl::protect_workspace_agents_dir; use super::workspace_acl::protect_workspace_codex_dir; use anyhow::Result; use std::collections::HashMap; @@ -344,6 +347,7 @@ mod windows_impl { if let Some(psid) = psid_workspace { allow_null_device(psid); let _ = protect_workspace_codex_dir(¤t_dir, psid); + let _ = protect_workspace_agents_dir(¤t_dir, psid); } } @@ -552,6 +556,7 @@ mod windows_impl { allow_null_device(psid_generic); allow_null_device(psid_workspace); let _ = protect_workspace_codex_dir(¤t_dir, psid_workspace); + let _ = protect_workspace_agents_dir(¤t_dir, psid_workspace); } Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index 28c94a769c..f3de7c8564 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -16,6 +16,7 @@ use codex_windows_sandbox::is_command_cwd_root; use codex_windows_sandbox::load_or_create_cap_sids; use codex_windows_sandbox::log_note; use codex_windows_sandbox::path_mask_allows; +use codex_windows_sandbox::protect_workspace_agents_dir; use codex_windows_sandbox::protect_workspace_codex_dir; use codex_windows_sandbox::sandbox_dir; use codex_windows_sandbox::sandbox_secrets_dir; @@ -788,9 +789,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( } } - // Protect the current workspace's `.codex` directory from tampering (write/delete) by using a - // workspace-specific capability SID. If `.codex` doesn't exist yet, skip it (it will be picked - // up on the next refresh). + // Protect the current workspace's `.codex` and `.agents` directories from tampering + // (write/delete) by using a workspace-specific capability SID. If a directory doesn't exist + // yet, skip it (it will be picked up on the next refresh). match unsafe { protect_workspace_codex_dir(&payload.command_cwd, workspace_psid) } { Ok(true) => { let cwd_codex = payload.command_cwd.join(".codex"); @@ -812,6 +813,30 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( )?; } } + match unsafe { protect_workspace_agents_dir(&payload.command_cwd, workspace_psid) } { + Ok(true) => { + let cwd_agents = payload.command_cwd.join(".agents"); + log_line( + log, + &format!( + "applied deny ACE to protect workspace .agents {}", + cwd_agents.display() + ), + )?; + } + Ok(false) => {} + Err(err) => { + let cwd_agents = payload.command_cwd.join(".agents"); + refresh_errors.push(format!( + "deny ACE failed on {}: {err}", + cwd_agents.display() + )); + log_line( + log, + &format!("deny ACE failed on {}: {err}", cwd_agents.display()), + )?; + } + } unsafe { if !sandbox_group_psid.is_null() { LocalFree(sandbox_group_psid as HLOCAL); diff --git a/codex-rs/windows-sandbox-rs/src/workspace_acl.rs b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs index b34a7191ad..7143212b54 100644 --- a/codex-rs/windows-sandbox-rs/src/workspace_acl.rs +++ b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs @@ -11,9 +11,19 @@ pub fn is_command_cwd_root(root: &Path, canonical_command_cwd: &Path) -> bool { /// # Safety /// Caller must ensure `psid` is a valid SID pointer. pub unsafe fn protect_workspace_codex_dir(cwd: &Path, psid: *mut c_void) -> Result { - let cwd_codex = cwd.join(".codex"); - if cwd_codex.is_dir() { - add_deny_write_ace(&cwd_codex, psid) + protect_workspace_subdir(cwd, psid, ".codex") +} + +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. +pub unsafe fn protect_workspace_agents_dir(cwd: &Path, psid: *mut c_void) -> Result { + protect_workspace_subdir(cwd, psid, ".agents") +} + +unsafe fn protect_workspace_subdir(cwd: &Path, psid: *mut c_void, subdir: &str) -> Result { + let path = cwd.join(subdir); + if path.is_dir() { + add_deny_write_ace(&path, psid) } else { Ok(false) } diff --git a/defs.bzl b/defs.bzl index db09ef0133..b3fe9903ea 100644 --- a/defs.bzl +++ b/defs.bzl @@ -170,20 +170,28 @@ def codex_rust_crate( cargo_env["CARGO_BIN_EXE_" + binary] = "$(rlocationpath %s)" % binary_label for test in native.glob(["tests/*.rs"], allow_empty = True): - test_name = name + "-" + test.removeprefix("tests/").removesuffix(".rs").replace("/", "-") + test_file_stem = test.removeprefix("tests/").removesuffix(".rs") + test_crate_name = test_file_stem.replace("-", "_") + test_name = name + "-" + test_file_stem.replace("/", "-") if not test_name.endswith("-test"): test_name += "-test" rust_test( name = test_name, + crate_name = test_crate_name, crate_root = test, srcs = [test], data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra, compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra, deps = maybe_lib + deps + dev_deps + integration_deps_extra, proc_macro_deps = proc_macro_deps + proc_macro_dev_deps, - rustc_flags = rustc_flags_extra, + # Keep `file!()` paths Cargo-like (`core/tests/...`) instead of + # Bazel workspace-prefixed (`codex-rs/core/tests/...`) for snapshot parity. + rustc_flags = rustc_flags_extra + ["--remap-path-prefix=codex-rs="], rustc_env = rustc_env, - env = test_env | cargo_env, + # Important: do not merge `test_env` here. Its unit-test-only + # `INSTA_WORKSPACE_ROOT="."` can point integration tests at the + # runfiles cwd and cause false `.snap.new` churn on Linux. + env = cargo_env, tags = test_tags, ) diff --git a/justfile b/justfile index 024a31ba2a..33d62d515b 100644 --- a/justfile +++ b/justfile @@ -51,6 +51,14 @@ test: bazel-codex *args: bazel run //codex-rs/cli:codex --run_under="cd $PWD &&" -- "$@" +[no-cd] +bazel-lock-update: + bazel mod deps --lockfile_mode=update + +[no-cd] +bazel-lock-check: + ./scripts/check-module-bazel-lock.sh + bazel-test: bazel test //... --keep_going diff --git a/scripts/check-module-bazel-lock.sh b/scripts/check-module-bazel-lock.sh new file mode 100755 index 0000000000..1a14889643 --- /dev/null +++ b/scripts/check-module-bazel-lock.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! bazel mod deps --lockfile_mode=error; then + echo "MODULE.bazel.lock is out of date." + echo "Run 'just bazel-lock-update' and commit the updated lockfile." + exit 1 +fi