diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 59a7820151..4cf6d6e37a 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -173,8 +173,11 @@ jobs: runner: macos-15-xlarge timeout_minutes: 30 - name: Windows - runner: windows-2022 + runner: windows-x64 timeout_minutes: 30 + runs_on: + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate diff --git a/MODULE.bazel b/MODULE.bazel index e9ec662d09..20d80f3962 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,18 +2,17 @@ module(name = "codex") bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "llvm", version = "0.7.1") +bazel_dep(name = "llvm", version = "0.7.9") -# The upstream LLVM archive contains a few unix-only symlink entries and is -# missing a couple of MinGW compatibility archives that windows-gnullvm needs -# during extraction and linking, so patch it until upstream grows native support. +# Patch hermetic LLVM for Codex's custom libc++ and Windows gnullvm runtime +# needs that have not landed upstream. single_version_override( module_name = "llvm", patch_strip = 1, patches = [ "//patches:llvm_rusty_v8_custom_libcxx.patch", "//patches:llvm_windows_arm64_powl.patch", - "//patches:llvm_windows_symlink_extract.patch", + "//patches:llvm_windows_mingw_compat.patch", ], ) @@ -80,7 +79,7 @@ use_repo(osx, "macos_sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") -bazel_dep(name = "rules_cc", version = "0.2.16") +bazel_dep(name = "rules_cc", version = "0.2.18") single_version_override( module_name = "rules_cc", patch_strip = 1, @@ -310,7 +309,7 @@ crate.annotation( # Build scripts compile in Bazel's exec configuration, so target-specific # optional build deps are otherwise dropped for the musl release platforms. build_script_deps = [ - "@crates//:openssl-src-300.5.5+3.5.5", + "@crates//:openssl-src-300.6.1+3.6.3", ], build_script_env = { "OPENSSL_DIR": "$(execpath @openssl//:gen_dir)", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 834b1acadd..8ae5bef17c 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -45,6 +45,7 @@ "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", "https://bcr.bazel.build/modules/bazel_features/1.42.0/MODULE.bazel": "e8ca15cb2639c5f12183db6dcb678735555d0cdd739b32a0418b6532b5e565f8", + "https://bcr.bazel.build/modules/bazel_features/1.43.0/MODULE.bazel": "defa2226f06ba20550d6548c3a2ea2a7929634437a52973869c20c225450eb91", "https://bcr.bazel.build/modules/bazel_features/1.45.0/MODULE.bazel": "7daec6d87ab0703417486d4cb948af0b06f55d4d7c08cbb5978c80e79b538edf", "https://bcr.bazel.build/modules/bazel_features/1.45.0/source.json": "635e4536e09ff125b8972e0fa239c135fde5f18701f7d5115680560651dfb41d", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", @@ -63,6 +64,7 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", @@ -87,8 +89,8 @@ "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/source.json": "3b116cbdbd25a68ffb587b672205f6d353a4c19a35452e480d58fc89531e0a10", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/llvm/0.7.0/MODULE.bazel": "3c07a4e5734b0ad41fe24dedaacbf3a35ce4377b7e1a21f24488a0c9ac4f1e6b", - "https://bcr.bazel.build/modules/llvm/0.7.1/MODULE.bazel": "74ac75efc6385b8a95d83bfa36ad399500f747c3d0f50287f9b6f9e854ec4814", - "https://bcr.bazel.build/modules/llvm/0.7.1/source.json": "0cac59d04dafa0ca1f70ea21cf1569e034ef8736c2c7510e834a9e8141d7e631", + "https://bcr.bazel.build/modules/llvm/0.7.9/MODULE.bazel": "452b272a6b5ecc8747db8e5b7fd9d6799ef0db17b8d437aa8d4dda4f56a3e9ea", + "https://bcr.bazel.build/modules/llvm/0.7.9/source.json": "0c098ab546a8c4b0a720dcb010714e13f9ba6ceab0d23a87b7f1083788928c31", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", @@ -143,7 +145,8 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/MODULE.bazel": "4460ec36adc8f722a6a2a4ac9374cb91f2acebadaa93fc37966129afb3dece87", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/source.json": "abad668ff2fd63ada1ac49bf386d37e27048b89a3465a6fd968bb832b00a09d3", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", @@ -221,8 +224,9 @@ "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/source.json": "20143442376c03426f6135292ba02d825cb75308aa47e6bf42dd4cc5a435c2ff", "https://bcr.bazel.build/modules/tar.bzl/0.9.0/MODULE.bazel": "452a22d7f02b1c9d7a22ab25edf20f46f3e1101f0f67dc4bfbf9a474ddf02445", - "https://bcr.bazel.build/modules/tar.bzl/0.9.0/source.json": "c732760a374831a2cf5b08839e4be75017196b4d796a5aa55235272ee17cd839", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", @@ -982,8 +986,8 @@ "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}", "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib-rs\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", "git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}", "gix-actor_0.40.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\",\"unicode\"],\"name\":\"bstr\",\"req\":\"^1.12.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"gix-date\",\"req\":\"^0.15.0\"},{\"name\":\"gix-error\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.114\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.14\"}],\"features\":{\"serde\":[\"dep:serde\",\"bstr/serde\",\"gix-date/serde\"]}}", @@ -1193,7 +1197,6 @@ "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", "libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", - "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "link-cplusplus_1.0.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"libc++\":[],\"libcxx\":[\"libc++\"],\"libstdc++\":[],\"libstdcxx\":[\"libstdc++\"],\"nothing\":[]}}", "link-section_0.17.2": "{\"dependencies\":[{\"features\":[\"link_section\"],\"name\":\"linktime-proc-macro\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"macrotest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2\"}],\"features\":{\"default\":[\"proc_macro\",\"std\"],\"proc_macro\":[\"dep:linktime-proc-macro\"],\"std\":[]}}", @@ -1290,7 +1293,7 @@ "openssl-macros_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "openssl-probe_0.1.6": "{\"dependencies\":[],\"features\":{}}", "openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}", - "openssl-src_300.5.5+3.5.5": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{\"camellia\":[],\"default\":[],\"force-engine\":[],\"idea\":[],\"ktls\":[],\"legacy\":[],\"no-dso\":[],\"seed\":[],\"ssl3\":[],\"weak-crypto\":[]}}", + "openssl-src_300.6.1+3.6.3": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{\"camellia\":[],\"default\":[],\"force-engine\":[],\"idea\":[],\"ktls\":[],\"legacy\":[],\"no-dso\":[],\"seed\":[],\"ssl3\":[],\"weak-crypto\":[]}}", "openssl-sys_0.9.111": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}", "openssl-sys_0.9.112": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.38\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}", "openssl_0.10.75": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.2.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"ffi\",\"package\":\"openssl-sys\",\"req\":\"^0.9.111\"},{\"name\":\"foreign-types\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.5.2\"},{\"name\":\"openssl-macros\",\"req\":\"^0.1.1\"}],\"features\":{\"aws-lc\":[\"ffi/aws-lc\"],\"aws-lc-fips\":[\"ffi/aws-lc-fips\"],\"bindgen\":[\"ffi/bindgen\"],\"default\":[],\"unstable_boringssl\":[\"ffi/unstable_boringssl\"],\"v101\":[],\"v102\":[],\"v110\":[],\"v111\":[],\"vendored\":[\"ffi/vendored\"]}}", @@ -1446,8 +1449,8 @@ "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "rfc6979_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", "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_1.7.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\":{\"local\":[]}}", - "rmcp_1.7.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"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\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"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\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"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.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.7.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\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"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\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-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\",\"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-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"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\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", + "rmcp-macros_1.8.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\":{\"local\":[]}}", + "rmcp_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"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\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"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\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"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.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.8.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\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"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\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-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\",\"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-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"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\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", "rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "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\"]}}", @@ -1872,6 +1875,7 @@ "zerovec_0.11.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.43.2\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"schemars\":[\"dep:schemars\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", "zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", + "zlib-rs_0.5.5": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zlib-rs_0.6.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-api\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 55d02cd8b1..d892aea620 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2136,6 +2136,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-cli", + "pretty_assertions", "serde", "serde_json", "tokio", @@ -2403,12 +2404,15 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", + "sha2 0.10.9", + "system-configuration", "tempfile", "thiserror 2.0.18", "tokio", "tracing", "tracing-opentelemetry", "tracing-subscriber", + "windows-sys 0.52.0", "zstd 0.13.3", ] @@ -2809,6 +2813,7 @@ dependencies = [ "codex-utils-plugins", "dirs", "dunce", + "futures", "pretty_assertions", "serde", "serde_json", @@ -3528,6 +3533,8 @@ dependencies = [ "rama-tls-rustls", "rama-unix", "rustls-native-certs", + "schannel", + "security-framework 3.5.1", "serde", "serde_json", "sha2 0.10.9", @@ -4015,6 +4022,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "app_test_support", "arboard", "assert_matches", "base64 0.22.1", @@ -6023,8 +6031,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", - "libz-sys", "miniz_oxide", + "zlib-rs 0.5.5", ] [[package]] @@ -6712,7 +6720,7 @@ dependencies = [ "prodash", "thiserror 2.0.18", "walkdir", - "zlib-rs", + "zlib-rs 0.6.3", ] [[package]] @@ -8765,17 +8773,6 @@ dependencies = [ "webrtc-sys", ] -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "link-cplusplus" version = "1.0.12" @@ -9739,9 +9736,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.5.5+3.5.5" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] @@ -11458,9 +11455,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64 0.22.1", @@ -11493,9 +11490,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -13603,7 +13600,7 @@ dependencies = [ [[package]] name = "tokio-tungstenite" version = "0.28.0" -source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6" +source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" dependencies = [ "futures-util", "log", @@ -14041,7 +14038,7 @@ dependencies = [ [[package]] name = "tungstenite" version = "0.27.0" -source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f" +source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" dependencies = [ "bytes", "data-encoding", @@ -15802,6 +15799,12 @@ dependencies = [ "zstd 0.13.3", ] +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e5f5025e91..17c2d1561b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -368,7 +368,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ regex = "1.12.3" regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } -rmcp = { version = "1.7.0", default-features = false } +rmcp = { version = "1.8.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustls = { version = "0.23", default-features = false, features = [ "aws_lc_rs", @@ -380,7 +380,7 @@ schemars = "0.8.22" seccompiler = "0.5.0" semver = "1.0" sentry = "0.46.0" -serde = "1" +serde = { version = "1", features = ["rc"] } serde_ignored = "0.1.14" serde_json = "1" serde_path_to_error = "0.1.20" @@ -409,6 +409,7 @@ strum_macros = "0.28.0" supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" +system-configuration = "0.7" tar = { version = "=0.4.45", default-features = false } tempfile = "3.23.0" test-log = "0.2.19" @@ -546,11 +547,11 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" } ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" } -tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" } -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } [patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"] -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index 0e29a38700..267fdf53d8 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -119,7 +119,7 @@ pub struct AgentIdentityJwtClaims { pub agent_private_key: String, pub account_id: String, pub chatgpt_user_id: String, - pub email: String, + pub email: Option, pub plan_type: AuthPlanType, pub chatgpt_account_is_fedramp: bool, } @@ -667,13 +667,33 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, } ); } + #[test] + fn decode_agent_identity_jwt_accepts_missing_email() { + let jwt = jwt_with_payload(serde_json::json!({ + "iss": AGENT_IDENTITY_JWT_ISSUER, + "aud": AGENT_IDENTITY_JWT_AUDIENCE, + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + + let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode"); + + assert_eq!(claims.email, None); + } + #[test] fn decode_agent_identity_jwt_maps_raw_plan_aliases() { let jwt = jwt_with_payload(serde_json::json!({ @@ -707,7 +727,7 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; @@ -739,7 +759,7 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a9a1d90e74..091a85e11f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3504,10 +3504,11 @@ async fn reducer_ingests_plugin_install_failed_fact() { async fn reducer_ingests_plugin_install_failed_fact_without_detail() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); - let mut plugin = PluginTelemetryMetadata::from_plugin_id( - &PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), - ); - plugin.remote_plugin_id = Some("plugins~Plugin_00000000000000000000000000000000".to_string()); + let plugin = PluginTelemetryMetadata { + plugin_id: PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string()), + capability_summary: None, + }; reducer .ingest( diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4ce6ac1a82..28597eff62 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1315,6 +1315,18 @@ ], "type": "object" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LegacyAppPathString": { "type": "string" }, @@ -1761,8 +1773,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], @@ -2301,10 +2314,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2357,10 +2370,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2411,10 +2424,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2461,10 +2474,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2504,10 +2517,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2558,10 +2571,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2601,10 +2614,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2644,10 +2657,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2691,10 +2704,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2743,10 +2756,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2795,10 +2808,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2833,10 +2846,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2882,10 +2895,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2909,16 +2922,6 @@ }, { "properties": { - "metadata": { - "anyOf": [ - { - "$ref": "#/definitions/ResponseItemMetadata" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" @@ -2947,10 +2950,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2989,23 +2992,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { @@ -6461,6 +6447,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6ba699dce7..b79e3cb13f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2583,6 +2583,49 @@ ], "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "showBufferingUi": { + "type": "boolean" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "showBufferingUi", + "threadId", + "turnId", + "useCases" + ], + "type": "object" + }, "ModelVerification": { "enum": [ "trustedAccessForCyber" @@ -2612,8 +2655,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], @@ -6608,6 +6652,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { 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 f8f8db58b5..597fd5a1b8 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 @@ -1904,6 +1904,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -5091,6 +5114,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -5857,7 +5900,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/v2/PlanType" @@ -10334,6 +10380,28 @@ "title": "GetAccountTokenUsageResponse", "type": "object" }, + "GetWorkspaceMessagesResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/v2/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -11080,6 +11148,18 @@ } ] }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12559,6 +12639,51 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "showBufferingUi": { + "type": "boolean" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "showBufferingUi", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { @@ -12638,8 +12763,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], @@ -12929,6 +13055,10 @@ }, "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -12942,6 +13072,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" @@ -13286,6 +13417,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -13293,6 +13435,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", @@ -14847,10 +14996,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -14903,10 +15052,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -14957,10 +15106,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15007,10 +15156,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15050,10 +15199,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15104,10 +15253,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15147,10 +15296,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15190,10 +15339,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15237,10 +15386,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15289,10 +15438,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15341,10 +15490,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15379,10 +15528,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15428,10 +15577,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15455,16 +15604,6 @@ }, { "properties": { - "metadata": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ResponseItemMetadata" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" @@ -15493,10 +15632,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15535,23 +15674,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { @@ -20508,6 +20630,49 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/v2/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 81a79c0c97..3af48cc6c0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -26,7 +26,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" @@ -2913,6 +2916,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -6705,6 +6731,28 @@ "title": "GetAccountTokenUsageResponse", "type": "object" }, + "GetWorkspaceMessagesResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -7504,6 +7552,18 @@ } ] }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8983,6 +9043,51 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "showBufferingUi": { + "type": "boolean" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "showBufferingUi", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { @@ -9062,8 +9167,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], @@ -9353,6 +9459,10 @@ }, "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -9366,6 +9476,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" @@ -9710,6 +9821,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -9717,6 +9839,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", @@ -11271,10 +11400,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11327,10 +11456,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11381,10 +11510,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11431,10 +11560,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11474,10 +11603,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11528,10 +11657,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11571,10 +11700,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11614,10 +11743,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11661,10 +11790,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11713,10 +11842,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11765,10 +11894,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11803,10 +11932,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11852,10 +11981,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11879,16 +12008,6 @@ }, { "properties": { - "metadata": { - "anyOf": [ - { - "$ref": "#/definitions/ResponseItemMetadata" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" @@ -11917,10 +12036,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11959,23 +12078,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { @@ -13412,6 +13514,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -18287,6 +18409,49 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 62b520de41..ca641799ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -22,7 +22,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json new file mode 100644 index 0000000000..4d1246a1b2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + } + }, + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json new file mode 100644 index 0000000000..ab542b6399 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "showBufferingUi": { + "type": "boolean" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "showBufferingUi", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json index 4d5a47f8d5..1027b9c5a0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json @@ -3,6 +3,10 @@ "definitions": { "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -16,6 +20,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json index ffe20e8c55..ce4ae5aa0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -134,6 +134,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -141,6 +152,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index d756f61a68..1002ad1a8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -134,6 +134,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -141,6 +152,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index bef85afddd..a2d2d442c5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -313,6 +313,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -320,6 +331,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index 7cffdeab9a..be97e20b65 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -108,6 +108,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -115,6 +126,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 22385f816f..01ac63c268 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -216,6 +216,18 @@ ], "type": "string" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LocalShellAction": { "oneOf": [ { @@ -383,10 +395,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -439,10 +451,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -493,10 +505,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -543,10 +555,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -586,10 +598,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -640,10 +652,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -683,10 +695,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -726,10 +738,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -773,10 +785,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -825,10 +837,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -877,10 +889,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -915,10 +927,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -964,10 +976,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -991,16 +1003,6 @@ }, { "properties": { - "metadata": { - "anyOf": [ - { - "$ref": "#/definitions/ResponseItemMetadata" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" @@ -1029,10 +1031,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1071,23 +1073,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 11efbb7e82..c19251667a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 528076c6ae..bc9cb524d6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -279,6 +279,18 @@ ], "type": "string" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LocalShellAction": { "oneOf": [ { @@ -454,10 +466,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -510,10 +522,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -564,10 +576,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -614,10 +626,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -657,10 +669,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -711,10 +723,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -754,10 +766,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -797,10 +809,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -844,10 +856,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -896,10 +908,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -948,10 +960,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -986,10 +998,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1035,10 +1047,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1062,16 +1074,6 @@ }, { "properties": { - "metadata": { - "anyOf": [ - { - "$ref": "#/definitions/ResponseItemMetadata" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" @@ -1100,10 +1102,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1142,23 +1144,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index de242b62b3..1a3b22f12c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 67a0f5c5d9..ca2b63634a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -109,8 +109,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index eebb5da960..9932456f1b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -195,8 +195,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 4ad961c22c..a754c6bc0f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 9460f80688..bec8b7d89a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -142,8 +142,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e014cc98b8..6af04ef401 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -88,4 +88,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * 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/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "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": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "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": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +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/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "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": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "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": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts b/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts new file mode 100644 index 0000000000..6ccf386884 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts @@ -0,0 +1,11 @@ +// 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. + +/** + * Internal Responses API passthrough metadata copied into underlying chat messages. + * + * Responses API strongly types this payload. Do not modify it without first getting API + * approval and making the corresponding Responses API change. + */ +export type InternalChatMessageMetadataPassthrough = { turn_id?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts index 04e465b8a2..e39784a8b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts +++ b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts @@ -11,10 +11,10 @@ * * When converting from [`PathUri`], "native" refers to the supplied * [`PathConvention`], which may be foreign to the operating system running - * this process. The inner string is private so path-producing code must convert - * from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing - * the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8 - * lossily because this API value is serialized as a JSON string. + * this process. The inner string is private so path-producing code must use a + * path conversion method instead of bypassing the intended conversion + * boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API + * value is serialized as a JSON string. * * Deserialization accepts any UTF-8 string without interpreting or validating * it. That unrestricted construction path is intentionally available only to diff --git a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts index 93b790a592..b59be94f8e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts @@ -3,7 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Controls whether the model should only spawn sub-agents after an explicit - * user request or may delegate proactively when doing so would help. + * Controls whether the model receives multi-agent delegation instructions and, + * when it does, whether it should only spawn sub-agents after an explicit user + * request or may delegate proactively when doing so would help. `none` leaves + * the multi-agent tools available without injecting delegation instructions. */ -export type MultiAgentMode = "explicitRequestOnly" | "proactive"; +export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index fc399b925f..62547d3e40 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -4,15 +4,15 @@ import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; +import type { InternalChatMessageMetadataPassthrough } from "./InternalChatMessageMetadataPassthrough"; import type { LocalShellAction } from "./LocalShellAction"; import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; -import type { ResponseItemMetadata } from "./ResponseItemMetadata"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, metadata?: ResponseItemMetadata, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "local_shell_call", /** * Legacy id field retained for compatibility with older payloads. */ @@ -20,4 +20,4 @@ id?: string, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", id?: string, encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", id?: string, encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 82814111dc..f3f828b8ac 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -30,6 +30,7 @@ import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOau import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; +import type { ModelSafetyBufferingUpdatedNotification } from "./v2/ModelSafetyBufferingUpdatedNotification"; import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { ProcessExitedNotification } from "./v2/ProcessExitedNotification"; @@ -73,4 +74,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/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "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": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "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": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7c94d9c601..dcfecf1282 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -41,6 +41,7 @@ export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; export type { InputModality } from "./InputModality"; +export type { InternalChatMessageMetadataPassthrough } from "./InternalChatMessageMetadataPassthrough"; export type { InternalSessionSource } from "./InternalSessionSource"; export type { LegacyAppPathString } from "./LegacyAppPathString"; export type { LocalShellAction } from "./LocalShellAction"; @@ -68,7 +69,6 @@ export type { Resource } from "./Resource"; export type { ResourceContent } from "./ResourceContent"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; -export type { ResponseItemMetadata } from "./ResponseItemMetadata"; export type { ReviewDecision } from "./ReviewDecision"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts index 09f74469b7..1b7953e5df 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -4,4 +4,4 @@ import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource"; import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts new file mode 100644 index 0000000000..949ad433a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts @@ -0,0 +1,14 @@ +// 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 { WorkspaceMessage } from "./WorkspaceMessage"; + +export type GetWorkspaceMessagesResponse = { +/** + * Whether the workspace-message backend route is available for this client. + */ +featureEnabled: boolean, +/** + * Active workspace messages returned by the backend. + */ +messages: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts new file mode 100644 index 0000000000..5abc9f3bf6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.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 ModelSafetyBufferingUpdatedNotification = { threadId: string, turnId: string, model: string, useCases: Array, reasons: Array, showBufferingUi: boolean, fasterModel: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts index 9d02fd776b..5796d30eed 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts @@ -10,4 +10,8 @@ id: string, /** * Optional user-facing description for display in clients. */ -description: string | null, }; +description: string | null, +/** + * Whether the effective requirements allow selecting this profile. + */ +allowed: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts index 4e97ee66f3..1e57d4974d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -21,10 +21,18 @@ composerIconUrl: string | null, * Local logo path, resolved from the installed plugin package. */ logo: AbsolutePathBuf | null, +/** + * Local dark-mode logo path, resolved from the installed plugin package. + */ +logoDark: AbsolutePathBuf | null, /** * Remote logo URL from the plugin catalog. */ logoUrl: string | null, +/** + * Remote dark-mode logo URL from the plugin catalog. + */ +logoUrlDark: string | null, /** * Local screenshot paths, resolved from the installed plugin package. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts new file mode 100644 index 0000000000..b024ce1163 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts @@ -0,0 +1,14 @@ +// 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 { WorkspaceMessageType } from "./WorkspaceMessageType"; + +export type WorkspaceMessage = { messageId: string, messageType: WorkspaceMessageType, messageBody: string, +/** + * Unix timestamp (in seconds) when the message was created. + */ +createdAt: number | null, +/** + * Unix timestamp (in seconds) when the message was archived. + */ +archivedAt: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts similarity index 64% rename from codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts index 365c561fd1..9d9438d964 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.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 ResponseItemMetadata = { turn_id?: string, source_call_id?: string, }; +export type WorkspaceMessageType = "headline" | "announcement" | "unknown"; 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 9b1bd3b6e8..3e769d3fab 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -157,6 +157,7 @@ export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse"; +export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse"; export type { GitInfo } from "./GitInfo"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; @@ -260,6 +261,7 @@ export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabil export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; +export type { ModelSafetyBufferingUpdatedNotification } from "./ModelSafetyBufferingUpdatedNotification"; export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { ModelVerification } from "./ModelVerification"; @@ -493,4 +495,6 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse"; export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification"; +export type { WorkspaceMessage } from "./WorkspaceMessage"; +export type { WorkspaceMessageType } from "./WorkspaceMessageType"; export type { WriteStatus } from "./WriteStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2f322aa5ff..ba63f59e9a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1022,6 +1022,12 @@ client_request_definitions! { response: v2::GetAccountTokenUsageResponse, }, + GetWorkspaceMessages => "account/workspaceMessages/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, + response: v2::GetWorkspaceMessagesResponse, + }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { params: v2::SendAddCreditsNudgeEmailParams, serialization: global("account-auth"), @@ -1655,6 +1661,7 @@ server_notification_definitions! { ModelVerification => "model/verification" (v2::ModelVerificationNotification), #[experimental("turn/moderationMetadata")] TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification), + ModelSafetyBufferingUpdated => "model/safetyBuffering/updated" (v2::ModelSafetyBufferingUpdatedNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), @@ -1700,6 +1707,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::account::AmazonBedrockCredentialSource; use codex_protocol::account::PlanType; + use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; @@ -2539,6 +2547,24 @@ mod tests { Ok(()) } + #[test] + fn serialize_get_workspace_messages() -> Result<()> { + let request = ClientRequest::GetWorkspaceMessages { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/workspaceMessages/read"); + assert_eq!( + json!({ + "method": "account/workspaceMessages/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_client_response() -> Result<()> { let cwd = absolute_path("/tmp"); @@ -2583,7 +2609,7 @@ mod tests { sandbox: v2::SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, - multi_agent_mode: None, + multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, }, }; @@ -2632,7 +2658,7 @@ mod tests { }, "activePermissionProfile": null, "reasoningEffort": null, - "multiAgentMode": null + "multiAgentMode": "explicitRequestOnly" } }), serde_json::to_value(&response)?, @@ -2828,7 +2854,7 @@ mod tests { ); let chatgpt = v2::Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Plus, }; assert_eq!( @@ -2840,6 +2866,19 @@ mod tests { serde_json::to_value(&chatgpt)?, ); + let chatgpt_without_email = v2::Account::Chatgpt { + email: None, + plan_type: PlanType::Pro, + }; + assert_eq!( + json!({ + "type": "chatgpt", + "email": null, + "planType": "pro", + }), + serde_json::to_value(&chatgpt_without_email)?, + ); + let codex_managed_bedrock = v2::Account::AmazonBedrock { credential_source: AmazonBedrockCredentialSource::CodexManaged, }; @@ -3347,6 +3386,37 @@ mod tests { Ok(()) } + #[test] + fn serialize_model_safety_buffering_updated_notification() -> Result<()> { + let notification = ServerNotification::ModelSafetyBufferingUpdated( + v2::ModelSafetyBufferingUpdatedNotification { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + model: "current-model".to_string(), + use_cases: vec!["cyber".to_string()], + reasons: vec!["user_risk".to_string()], + show_buffering_ui: true, + faster_model: Some("faster-model".to_string()), + }, + ); + assert_eq!( + json!({ + "method": "model/safetyBuffering/updated", + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "model": "current-model", + "useCases": ["cyber"], + "reasons": ["user_risk"], + "showBufferingUi": true, + "fasterModel": "faster-model" + } + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + #[test] fn serialize_thread_realtime_output_audio_delta_notification() -> Result<()> { let notification = ServerNotification::ThreadRealtimeOutputAudioDelta( diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index c1062c2cdc..7fb764bb38 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -3349,6 +3349,8 @@ mod tests { message: String::new(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { @@ -3781,7 +3783,7 @@ mod tests { text: "plain text".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 2a71a092c9..635d001bba 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -25,7 +25,11 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, + Chatgpt { + #[schemars(required, schema_with = "nullable_string_schema")] + email: Option, + plan_type: PlanType, + }, #[serde(rename = "amazonBedrock", rename_all = "camelCase")] #[ts(rename = "amazonBedrock", rename_all = "camelCase")] @@ -35,6 +39,12 @@ pub enum Account { }, } +fn nullable_string_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + generator.subschema_for::>() +} + fn default_bedrock_credential_source() -> AmazonBedrockCredentialSource { AmazonBedrockCredentialSource::AwsManaged } @@ -314,6 +324,41 @@ pub struct GetAccountTokenUsageResponse { pub daily_usage_buckets: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetWorkspaceMessagesResponse { + /// Whether the workspace-message backend route is available for this client. + pub feature_enabled: bool, + /// Active workspace messages returned by the backend. + pub messages: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorkspaceMessage { + pub message_id: String, + pub message_type: WorkspaceMessageType, + pub message_body: String, + /// Unix timestamp (in seconds) when the message was created. + #[ts(type = "number | null")] + pub created_at: Option, + /// Unix timestamp (in seconds) when the message was archived. + #[ts(type = "number | null")] + pub archived_at: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum WorkspaceMessageType { + Headline, + Announcement, + #[serde(other)] + Unknown, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index d8fa249582..bb6c411124 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -162,3 +162,16 @@ pub struct TurnModerationMetadataNotification { pub turn_id: String, pub metadata: JsonValue, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelSafetyBufferingUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub model: String, + pub use_cases: Vec, + pub reasons: Vec, + pub show_buffering_ui: bool, + pub faster_model: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 7434fe8653..932e44e267 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -378,6 +378,8 @@ pub struct PermissionProfileSummary { pub id: String, /// Optional user-facing description for display in clients. pub description: Option, + /// Whether the effective requirements allow selecting this profile. + pub allowed: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 128425d1ce..dbe0b3d55d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -716,8 +716,12 @@ pub struct PluginInterface { pub composer_icon_url: Option, /// Local logo path, resolved from the installed plugin package. pub logo: Option, + /// Local dark-mode logo path, resolved from the installed plugin package. + pub logo_dark: Option, /// Remote logo URL from the plugin catalog. pub logo_url: Option, + /// Remote dark-mode logo URL from the plugin catalog. + pub logo_url_dark: Option, /// Local screenshot paths, resolved from the installed plugin package. pub screenshots: Vec, /// Remote screenshot URLs from the plugin catalog. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 0161cd4ef9..ba69b6d6e2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::FileChangeItem; @@ -2937,6 +2938,13 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { }; let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); let composer_icon_json = composer_icon.as_path().display().to_string(); + let logo_dark = if cfg!(windows) { + r"C:\plugins\linear\logo-dark.png" + } else { + "/plugins/linear/logo-dark.png" + }; + let logo_dark = AbsolutePathBuf::try_from(PathBuf::from(logo_dark)).unwrap(); + let logo_dark_json = logo_dark.as_path().display().to_string(); let interface = PluginInterface { display_name: Some("Linear".to_string()), @@ -2953,7 +2961,9 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { composer_icon: Some(composer_icon), composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), logo: None, + logo_dark: Some(logo_dark), logo_url: Some("https://example.com/linear/logo.png".to_string()), + logo_url_dark: Some("https://example.com/linear/logo-dark.png".to_string()), screenshots: Vec::new(), screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], }; @@ -2975,7 +2985,9 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { "composerIcon": composer_icon_json, "composerIconUrl": "https://example.com/linear/icon.png", "logo": null, + "logoDark": logo_dark_json, "logoUrl": "https://example.com/linear/logo.png", + "logoUrlDark": "https://example.com/linear/logo-dark.png", "screenshots": [], "screenshotUrls": ["https://example.com/linear/screenshot.png"], }), @@ -3708,7 +3720,11 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { resume.multi_agent_mode, fork.multi_agent_mode, ), - (None, None, None) + ( + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + ) ); let foreign_source: LegacyAppPathString = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 3c527d6219..6a51f71e51 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -94,9 +94,9 @@ pub struct ThreadStartParams { pub developer_instructions: Option, #[ts(optional = nullable)] pub personality: Option, - /// Set the initial multi-agent mode for this thread. - /// Omitted leaves the thread without a selected mode. Eligible multi-agent - /// v2 turns still default to `explicitRequestOnly`. + /// Set the initial multi-agent mode for this thread. `none` leaves the + /// multi-agent tools available without injecting mode instructions. + /// Omitted defaults to `explicitRequestOnly`. #[experimental("thread/start.multiAgentMode")] #[ts(optional = nullable)] pub multi_agent_mode: Option, @@ -186,10 +186,10 @@ pub struct ThreadStartResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/start.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } impl ThreadStartResponse { @@ -279,10 +279,10 @@ pub struct ThreadSettings { pub effort: Option, pub summary: Option, pub collaboration_mode: CollaborationMode, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/settings.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, pub personality: Option, } @@ -419,10 +419,10 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/resume.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, /// `thread/turns/list` page returned when requested by `initialTurnsPage`. #[experimental("thread/resume.initialTurnsPage")] #[serde(default)] @@ -578,10 +578,10 @@ pub struct ThreadForkResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/fork.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } impl ThreadForkResponse { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 7ecb9f94b7..1fc35d1515 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -151,8 +151,9 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub collaboration_mode: Option, - /// Controls whether multi-agent v2 delegation requires an explicit user request. - /// Omitted keeps the loaded session's current mode. + /// Controls multi-agent v2 delegation instructions. `none` leaves the + /// multi-agent tools available without injecting mode instructions. Omitted + /// keeps the loaded session's current mode. #[experimental("turn/start.multiAgentMode")] #[ts(optional = nullable)] pub multi_agent_mode: Option, diff --git a/codex-rs/app-server-test-client/Cargo.toml b/codex-rs/app-server-test-client/Cargo.toml index 631b40207f..901afb70d0 100644 --- a/codex-rs/app-server-test-client/Cargo.toml +++ b/codex-rs/app-server-test-client/Cargo.toml @@ -26,3 +26,6 @@ uuid = { workspace = true, features = ["v4"] } [lib] doctest = false + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/app-server-test-client/README.md b/codex-rs/app-server-test-client/README.md index 5499706b93..44687672a0 100644 --- a/codex-rs/app-server-test-client/README.md +++ b/codex-rs/app-server-test-client/README.md @@ -18,6 +18,10 @@ cargo run -p codex-app-server-test-client -- \ cargo run -p codex-app-server-test-client -- model-list ``` +`send-message` and `send-message-v2` handle `request_user_input` server requests interactively. +When Codex asks a question, choose a numbered option (or `o` for a free-form answer when offered) +and the client will send the response and continue streaming the same turn. + ## Testing Plugin Analytics The `plugin-analytics-smoke` command exercises `plugin/installed`, plugin diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 856d75585e..8e46308575 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -91,6 +91,7 @@ mod loopback_responses_server; mod plugin_analytics_capture; mod plugin_analytics_mutation_smoke; mod plugin_analytics_smoke; +mod request_user_input; const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ // v2 item deltas. @@ -2040,6 +2041,10 @@ impl CodexClient { ServerRequest::FileChangeRequestApproval { request_id, params } => { self.approve_file_change_request(request_id, params)?; } + ServerRequest::ToolRequestUserInput { request_id, params } => { + let response = request_user_input::prompt_for_answers(¶ms)?; + self.send_server_request_response(request_id, &response)?; + } other => { bail!("received unsupported server request: {other:?}"); } diff --git a/codex-rs/app-server-test-client/src/request_user_input.rs b/codex-rs/app-server-test-client/src/request_user_input.rs new file mode 100644 index 0000000000..0302f33600 --- /dev/null +++ b/codex-rs/app-server-test-client/src/request_user_input.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use std::io; +use std::io::BufRead; +use std::io::IsTerminal; +use std::io::Write; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use codex_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputResponse; + +pub(super) fn prompt_for_answers( + params: &ToolRequestUserInputParams, +) -> Result { + let stdin = io::stdin(); + if !stdin.is_terminal() { + bail!("request_user_input requires an interactive stdin terminal"); + } + + let stdout = io::stdout(); + prompt_for_answers_with(&mut stdin.lock(), &mut stdout.lock(), params) +} + +fn prompt_for_answers_with( + input: &mut impl BufRead, + output: &mut impl Write, + params: &ToolRequestUserInputParams, +) -> Result { + writeln!( + output, + "\n[request_user_input for thread {}, turn {}]", + params.thread_id, params.turn_id + )?; + if let Some(auto_resolution_ms) = params.auto_resolution_ms { + writeln!( + output, + "The app-server may auto-resolve this request after {auto_resolution_ms} ms." + )?; + } + + let mut answers = HashMap::new(); + for question in ¶ms.questions { + writeln!(output, "\n{}: {}", question.header, question.question)?; + let options = question + .options + .as_deref() + .filter(|options| !options.is_empty()); + let answer_values = if let Some(options) = options { + for (index, option) in options.iter().enumerate() { + writeln!( + output, + " {}. {} - {}", + index + 1, + option.label, + option.description + )?; + } + if question.is_other { + writeln!(output, " o. Other (free-form)")?; + } + + loop { + if question.is_other { + write!(output, "Choose 1-{} or o: ", options.len())?; + } else { + write!(output, "Choose 1-{}: ", options.len())?; + } + output.flush()?; + + let mut line = String::new(); + if input + .read_line(&mut line) + .context("failed to read request_user_input selection")? + == 0 + { + bail!("stdin closed while waiting for request_user_input selection"); + } + let selection = line.trim(); + + if let Ok(index) = selection.parse::() + && let Some(option) = index.checked_sub(1).and_then(|index| options.get(index)) + { + break vec![option.label.clone()]; + } + + if let Some(option) = options + .iter() + .find(|option| option.label.eq_ignore_ascii_case(selection)) + { + break vec![option.label.clone()]; + } + + if question.is_other && selection.eq_ignore_ascii_case("o") { + write!(output, "Other: ")?; + output.flush()?; + line.clear(); + if input + .read_line(&mut line) + .context("failed to read request_user_input free-form answer")? + == 0 + { + bail!("stdin closed while waiting for request_user_input free-form answer"); + } + let answer = line.trim(); + if !answer.is_empty() { + break vec![format!("user_note: {answer}")]; + } + } + + writeln!(output, "Invalid selection; try again.")?; + } + } else { + loop { + write!(output, "Answer: ")?; + output.flush()?; + + let mut line = String::new(); + if input + .read_line(&mut line) + .context("failed to read request_user_input answer")? + == 0 + { + bail!("stdin closed while waiting for request_user_input answer"); + } + let answer = line.trim(); + if !answer.is_empty() { + break vec![format!("user_note: {answer}")]; + } + writeln!(output, "Answer cannot be empty; try again.")?; + } + }; + + answers.insert( + question.id.clone(), + ToolRequestUserInputAnswer { + answers: answer_values, + }, + ); + } + + Ok(ToolRequestUserInputResponse { answers }) +} + +#[cfg(test)] +#[path = "request_user_input_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-test-client/src/request_user_input_tests.rs b/codex-rs/app-server-test-client/src/request_user_input_tests.rs new file mode 100644 index 0000000000..d8b2a4699d --- /dev/null +++ b/codex-rs/app-server-test-client/src/request_user_input_tests.rs @@ -0,0 +1,125 @@ +use std::collections::HashMap; +use std::io::Cursor; + +use codex_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use pretty_assertions::assert_eq; + +use super::prompt_for_answers_with; + +#[test] +fn collects_option_and_free_form_answers() { + let params = ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + questions: vec![ + ToolRequestUserInputQuestion { + id: "target".to_string(), + header: "Target".to_string(), + question: "Which target?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Core".to_string(), + description: "Inspect core".to_string(), + }, + ToolRequestUserInputOption { + label: "TUI".to_string(), + description: "Inspect TUI".to_string(), + }, + ]), + }, + ToolRequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "Anything else?".to_string(), + is_other: true, + is_secret: false, + options: None, + }, + ], + auto_resolution_ms: Some(60_000), + }; + let mut input = Cursor::new(b"2\ninclude snapshots\n"); + let mut output = Vec::new(); + + let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap(); + + assert_eq!( + response, + ToolRequestUserInputResponse { + answers: HashMap::from([ + ( + "target".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["TUI".to_string()], + }, + ), + ( + "details".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["user_note: include snapshots".to_string()], + }, + ), + ]), + } + ); + assert_eq!( + String::from_utf8(output).unwrap(), + concat!( + "\n[request_user_input for thread thread-1, turn turn-1]\n", + "The app-server may auto-resolve this request after 60000 ms.\n", + "\nTarget: Which target?\n", + " 1. Core - Inspect core\n", + " 2. TUI - Inspect TUI\n", + " o. Other (free-form)\n", + "Choose 1-2 or o: ", + "\nDetails: Anything else?\n", + "Answer: ", + ) + ); +} + +#[test] +fn retries_invalid_selection_and_collects_other_answer() { + let params = ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "target".to_string(), + header: "Target".to_string(), + question: "Which target?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "Core".to_string(), + description: "Inspect core".to_string(), + }]), + }], + auto_resolution_ms: None, + }; + let mut input = Cursor::new(b"9\no\nSDK wrapper\n"); + let mut output = Vec::new(); + + let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap(); + + assert_eq!( + response, + ToolRequestUserInputResponse { + answers: HashMap::from([( + "target".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["user_note: SDK wrapper".to_string()], + }, + )]), + } + ); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Invalid selection; try again.")); +} diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index 175890962e..7ad9444921 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -57,3 +57,4 @@ chrono = { workspace = true } codex-config = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs index 22e5e74eda..aef2bfd25d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs @@ -692,14 +692,14 @@ mod tests { } } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn initialize_timeout_closes_open_connection() { let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); let shutdown_token = CancellationToken::new(); let client_tracker = ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); - let mut handle_message = tokio::spawn(async move { + let handle_message = tokio::spawn(async move { let mut client_tracker = client_tracker; client_tracker .handle_message(initialize_envelope_with_stream_id( @@ -709,13 +709,13 @@ mod tests { .await }); - assert!( - timeout(Duration::from_millis(50), &mut handle_message) - .await - .expect("initialize timeout rollback should not wait for close delivery") - .expect("handle message task should not panic") - .is_err() - ); + tokio::task::yield_now().await; + tokio::time::advance( + REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT + Duration::from_millis(1), + ) + .await; + + assert!(handle_message.await.expect("handle message task").is_err()); let connection_id = match transport_event_rx.recv().await.expect("open event") { TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, other => panic!("expected connection opened, got {other:?}"), diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 6c453e6f9d..44299c4b57 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1041,6 +1041,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let (transport_event_tx, _transport_event_rx) = @@ -1870,6 +1871,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); @@ -1966,6 +1968,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_control_target = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 6354e435c9..21ec02ab06 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -185,6 +185,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -270,6 +271,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 190526d720..380f13984e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -539,6 +539,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -808,6 +809,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_handle = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 239d5d9ea7..b9f1c71752 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -2214,6 +2214,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2312,6 +2313,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2435,6 +2437,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f8ceb69d42..50f0052094 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -137,17 +137,17 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode; omission leaves the selected mode unset, while eligible multi-agent v2 turns still default to `explicitRequestOnly`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode and defaults to `explicitRequestOnly` when omitted; use `none` to keep multi-agent tools available without injecting mode instructions. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current selected mode or `null` when no mode was selected. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current mode. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. -- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `explicitRequestOnly` or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `none`, `explicitRequestOnly`, or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. @@ -168,7 +168,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids. - `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current selected mode, including an unset mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns default an unset mode to explicit-request-only, use the selected mode when `features.multi_agent_mode` is enabled, and otherwise use explicit-request-only developer instructions. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `none`, `explicitRequestOnly`, or `proactive`; `none` keeps the tools available without injecting mode instructions, and omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations, and eligible multi-agent v2 turns use it directly. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `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"`. @@ -202,7 +202,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. +- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. @@ -714,9 +714,11 @@ If the thread does not already have an active turn, the server starts a standalo Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: - `{"type":"text","text":"Explain this diff"}` -- `{"type":"image","url":"https://…png"}` +- `{"type":"image","url":"data:image/png;base64,…"}` - `{"type":"localImage","path":"/tmp/screenshot.png"}` +The `image` variant accepts inline data URLs. Remote HTTP(S) image URLs are rejected; use a data URL or `localImage` instead. + You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. `approvalsReviewer` accepts: @@ -828,7 +830,7 @@ Invoke a plugin by including a UI mention token such as `@sample` in the text in ### Example: Inject raw history items -Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. +Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. Any `input_image` items must use inline data URLs; remote HTTP(S) image URLs are rejected. ```json { "method": "thread/inject_items", "id": 36, "params": { @@ -1353,6 +1355,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/safetyBuffering/updated` — `{ threadId, turnId, model, useCases, reasons, showBufferingUi, fasterModel }` when a response enters safety buffering. `fasterModel` is nullable. This notification is transient and is not persisted in rollout history. - `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). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. - `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. @@ -1584,7 +1587,7 @@ The server also emits item lifecycle notifications around the request: 3. Client response. 4. `item/completed` with `item.type = "dynamicToolCall"`, final `status`, and the returned `contentItems`/`success`. -The client must respond with content items. Use `inputText` for text and `inputImage` for image URLs/data URLs: +The client must respond with content items. Use `inputText` for text and `inputImage` for inline data URLs. Remote HTTP(S) image URLs make the dynamic tool response invalid. ```json { @@ -1919,6 +1922,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only. - `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key. - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. +- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. @@ -1939,6 +1943,7 @@ Response examples: { "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) { "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": null, "planType": "enterprise" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "codexManaged" }, "requiresOpenaiAuth": false } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "awsManaged" }, "requiresOpenaiAuth": false } } ``` @@ -1946,6 +1951,7 @@ Response examples: Field notes: - `refreshToken` (bool): set `true` to force a token refresh. +- `email` is `null` when the ChatGPT account does not have an email address. - `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. - Amazon Bedrock reports `credentialSource: "codexManaged"` when it uses a Bedrock API key managed by Codex. Otherwise it reports `credentialSource: "awsManaged"` for the external AWS credential path. This identifies the selected credential source; it does not validate that the AWS credential chain can resolve credentials. @@ -2045,7 +2051,18 @@ Field notes: - `noCredit` means the account has no earned reset credits available. - Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response. -### 9) Notify a workspace owner about a limit +### 9) Workspace messages (ChatGPT) + +```json +{ "method": "account/workspaceMessages/read", "id": 9 } +{ "id": 9, "result": { "featureEnabled": true, "messages": [ + { "messageId": "msg_123", "messageType": "headline", "messageBody": "Workspace maintenance starts at 5pm.", "createdAt": 1781395200, "archivedAt": null } +] } } +``` + +When the upstream workspace-message feature is disabled, `featureEnabled` is `false` and `messages` is empty. + +### 10) Notify a workspace owner about a limit ```json { "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a77b0f4664..36dbdd9d3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -40,6 +40,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification; use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; @@ -351,6 +352,22 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::TurnModerationMetadata(notification)) .await; } + EventMsg::SafetyBuffering(event) => { + let notification = ModelSafetyBufferingUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + model: event.model, + use_cases: event.use_cases, + reasons: event.reasons, + show_buffering_ui: event.show_buffering_ui, + faster_model: event.faster_model, + }; + outgoing + .send_server_notification(ServerNotification::ModelSafetyBufferingUpdated( + notification, + )) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/src/dynamic_tools.rs b/codex-rs/app-server/src/dynamic_tools.rs index c5e7550d9f..0da9cad453 100644 --- a/codex-rs/app-server/src/dynamic_tools.rs +++ b/codex-rs/app-server/src/dynamic_tools.rs @@ -8,6 +8,8 @@ use std::sync::Arc; use tokio::sync::oneshot; use tracing::error; +use crate::image_url::REMOTE_IMAGE_URL_ERROR; +use crate::image_url::is_remote_image_url; use crate::outgoing_message::ClientRequestResult; use crate::server_request_error::is_turn_transition_server_request_error; @@ -54,6 +56,21 @@ pub(crate) async fn on_call_response( fn decode_response(value: serde_json::Value) -> (DynamicToolCallResponse, Option) { match serde_json::from_value::(value) { + Ok(response) + if response.content_items.iter().any(|item| { + matches!( + item, + DynamicToolCallOutputContentItem::InputImage { image_url } + if is_remote_image_url(image_url) + ) + }) => + { + error!( + message = REMOTE_IMAGE_URL_ERROR, + "dynamic tool response was invalid" + ); + fallback_response(REMOTE_IMAGE_URL_ERROR) + } Ok(response) => (response, None), Err(err) => { error!("failed to deserialize DynamicToolCallResponse: {err}"); diff --git a/codex-rs/app-server/src/image_url.rs b/codex-rs/app-server/src/image_url.rs new file mode 100644 index 0000000000..d6e21f791d --- /dev/null +++ b/codex-rs/app-server/src/image_url.rs @@ -0,0 +1,8 @@ +pub(crate) const REMOTE_IMAGE_URL_ERROR: &str = + "remote image URLs are not supported; use an inline data URL instead"; + +pub(crate) fn is_remote_image_url(image_url: &str) -> bool { + image_url.split_once(':').is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index f4cd83d732..9eb0009464 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -68,13 +68,11 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use tracing::Level; use tracing::error; use tracing::info; use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; -use tracing_subscriber::filter::Targets; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; @@ -98,6 +96,7 @@ mod extensions; mod filters; mod fs_watch; mod fuzzy_file_search; +mod image_url; pub mod in_process; mod mcp_refresh; mod message_processor; @@ -666,7 +665,7 @@ pub async fn run_main_with_transport_options( let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() - .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + .map(|layer| layer.with_filter(log_db::default_filter())); let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); let _ = tracing_subscriber::registry() diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ad29e05010..33cf142fe8 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1427,6 +1427,9 @@ impl MessageProcessor { ClientRequest::GetAccountTokenUsage { .. } => { self.account_processor.get_account_token_usage().await } + ClientRequest::GetWorkspaceMessages { .. } => { + self.account_processor.get_workspace_messages().await + } ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { self.account_processor .send_add_credits_nudge_email(params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8c202a806d..35df59d5cc 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -76,6 +76,7 @@ use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; +use codex_app_server_protocol::GetWorkspaceMessagesResponse; use codex_app_server_protocol::GitDiffToRemoteParams; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; @@ -282,10 +283,16 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; +use codex_app_server_protocol::WorkspaceMessage; +use codex_app_server_protocol::WorkspaceMessageType; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; +use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage; +use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType; +use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse; use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode; +use codex_backend_client::RequestError as BackendRequestError; use codex_backend_client::TokenUsageProfile; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; @@ -330,7 +337,6 @@ use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; use codex_core_plugins::PluginsManager; use codex_core_plugins::loader::load_plugin_apps; use codex_core_plugins::loader::load_plugin_mcp_servers; -use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; use codex_core_plugins::manifest::PluginManifestInterface; use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; @@ -390,9 +396,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 5d234f7002..6aa624153d 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -1,10 +1,13 @@ use super::*; +use chrono::DateTime; mod rate_limit_resets; // Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); +const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration = + Duration::from_millis(/*millis*/ 1000); // The override is intentionally available only in debug builds, matching the login path below. #[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; @@ -142,6 +145,14 @@ impl AccountRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn get_workspace_messages( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_workspace_messages_response() + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn send_add_credits_nudge_email( &self, params: SendAddCreditsNudgeEmailParams, @@ -348,6 +359,7 @@ impl AccountRequestProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) }; #[cfg(debug_assertions)] @@ -943,6 +955,48 @@ impl AccountRequestProcessor { Ok(Self::account_token_usage_response(profile)) } + async fn get_workspace_messages_response( + &self, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read workspace messages", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read workspace messages", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let messages = tokio::time::timeout( + ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT, + client.list_workspace_messages(), + ) + .await + .map_err(|_| internal_error("workspace messages fetch timed out"))?; + + match messages { + Ok(messages) => { + Self::workspace_messages_response(messages, /*feature_enabled*/ true) + } + Err(err) if workspace_messages_feature_disabled(&err) => { + Self::workspace_messages_response( + BackendWorkspaceMessagesResponse { + messages: Vec::new(), + }, + /*feature_enabled*/ false, + ) + } + Err(err) => Err(internal_error(format!( + "failed to fetch workspace messages: {err}" + ))), + } + } + fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse { let stats = profile.stats; GetAccountTokenUsageResponse { @@ -965,6 +1019,20 @@ impl AccountRequestProcessor { } } + fn workspace_messages_response( + messages: BackendWorkspaceMessagesResponse, + feature_enabled: bool, + ) -> Result { + Ok(GetWorkspaceMessagesResponse { + feature_enabled, + messages: messages + .messages + .into_iter() + .map(workspace_message_from_backend) + .collect::, _>>()?, + }) + } + async fn send_add_credits_nudge_email_response( &self, params: SendAddCreditsNudgeEmailParams, @@ -1015,6 +1083,48 @@ impl AccountRequestProcessor { } } +fn workspace_message_from_backend( + message: BackendWorkspaceMessage, +) -> Result { + Ok(WorkspaceMessage { + message_id: message.message_id, + message_type: workspace_message_type_from_backend(message.message_type), + message_body: message.message_body, + created_at: workspace_message_timestamp_from_backend(message.created_at)?, + archived_at: workspace_message_timestamp_from_backend(message.archived_at)?, + }) +} + +fn workspace_message_timestamp_from_backend( + timestamp: Option, +) -> Result, JSONRPCErrorError> { + timestamp + .map(|timestamp| { + DateTime::parse_from_rfc3339(×tamp) + .map(|timestamp| timestamp.timestamp()) + .map_err(|err| { + internal_error(format!( + "failed to parse workspace message timestamp `{timestamp}`: {err}" + )) + }) + }) + .transpose() +} + +fn workspace_message_type_from_backend( + message_type: BackendWorkspaceMessageType, +) -> WorkspaceMessageType { + match message_type { + BackendWorkspaceMessageType::Headline => WorkspaceMessageType::Headline, + BackendWorkspaceMessageType::Announcement => WorkspaceMessageType::Announcement, + BackendWorkspaceMessageType::Unknown => WorkspaceMessageType::Unknown, + } +} + +fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool { + err.status().is_some_and(|status| status.as_u16() == 404) +} + #[cfg(test)] mod tests { use super::*; @@ -1055,4 +1165,55 @@ mod tests { } ); } + + #[test] + fn workspace_messages_response_maps_backend_messages() { + let response = AccountRequestProcessor::workspace_messages_response( + BackendWorkspaceMessagesResponse { + messages: vec![BackendWorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: BackendWorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some("2026-06-14T00:00:00Z".to_string()), + archived_at: Some("2026-06-15T00:00:00Z".to_string()), + }], + }, + /*feature_enabled*/ true, + ) + .expect("workspace message timestamps should parse"); + + assert_eq!( + response, + GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some(1_781_395_200), + archived_at: Some(1_781_481_600), + }], + } + ); + } + + #[test] + fn workspace_messages_feature_disabled_only_for_not_found() { + let cases = [ + (reqwest::StatusCode::NOT_FOUND, true), + (reqwest::StatusCode::UNAUTHORIZED, false), + (reqwest::StatusCode::FORBIDDEN, false), + ]; + + for (status, expected) in cases { + let err = BackendRequestError::UnexpectedStatus { + method: "GET".to_string(), + url: "https://example.test/api/codex/workspace-messages".to_string(), + status, + content_type: "application/json".to_string(), + body: "{}".to_string(), + }; + assert_eq!(workspace_messages_feature_disabled(&err), expected); + } + } } diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index f61d0ed4b6..9542340200 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -1,5 +1,5 @@ use super::*; -use codex_config::config_toml::ConfigToml; +use codex_core::config::permission_profile_catalog; use futures::StreamExt; #[derive(Clone)] @@ -434,35 +434,15 @@ impl CatalogRequestProcessor { .await .map_err(|err| internal_error(format!("failed to reload config: {err}")))?, }; - let effective_config: ConfigToml = config_layer_stack - .effective_config() - .try_into() - .map_err(|err| internal_error(format!("failed to read effective config: {err}")))?; - let mut profiles = vec![ - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), - description: None, - }, - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), - description: None, - }, - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), - description: None, - }, - ]; - let mut configured_profiles = effective_config - .permissions + let profiles = permission_profile_catalog(&config_layer_stack) + .map_err(|err| internal_error(format!("failed to resolve permission profiles: {err}")))? .into_iter() - .flat_map(|permissions| permissions.entries) - .map(|(id, profile)| PermissionProfileSummary { - id, + .map(|profile| PermissionProfileSummary { + id: profile.id, description: profile.description, + allowed: profile.allowed, }) .collect::>(); - configured_profiles.sort_by(|left, right| left.id.cmp(&right.id)); - profiles.extend(configured_profiles); let total = profiles.len(); let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; let effective_limit = effective_limit.min(total); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 15c3b7c864..50bba54009 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -295,15 +295,14 @@ impl ConfigRequestProcessor { &self, pending_changes: std::collections::BTreeMap, ) { + let plugins_manager = self.thread_manager.plugins_manager(); for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { continue; }; - let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata( - self.config_manager.codex_home(), - &plugin_id, - ) - .await; + let metadata = plugins_manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; if enabled { self.analytics_events_client.track_plugin_enabled(metadata); } else { diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index 3c89f44310..8f184d4845 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -200,6 +200,7 @@ impl ExternalAgentSessionImporter { }; let now = Utc::now(); let create_params = CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index e9bf044766..76123b6aa2 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -23,7 +23,6 @@ use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; use codex_plugin::PluginId; -use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -78,7 +77,9 @@ fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginI composer_icon: interface.composer_icon, composer_icon_url: None, logo: interface.logo, + logo_dark: interface.logo_dark, logo_url: None, + logo_url_dark: None, screenshots: interface.screenshots, screenshot_urls: Vec::new(), } @@ -746,11 +747,10 @@ impl PluginRequestProcessor { ); } - let featured_plugin_ids = if !plugins_input.remote_plugin_enabled - && data - .iter() - .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - { + let featured_plugin_ids = if data.iter().any(|marketplace| { + marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME + || marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME + }) { match plugins_manager .featured_plugin_ids_for_config(&plugins_input, auth.as_ref()) .await @@ -1620,9 +1620,14 @@ impl PluginRequestProcessor { Some(self.effective_plugins_changed_callback()), ); - let mut plugin_metadata = - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; - plugin_metadata.remote_plugin_id = Some(remote_plugin_id.clone()); + let plugin_metadata = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_installed_plugin_with_remote_id( + &result.plugin_id, + &remote_plugin_id, + ) + .await; self.analytics_events_client .track_plugin_installed(plugin_metadata); @@ -1713,8 +1718,10 @@ impl PluginRequestProcessor { else { return; }; - let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id); - plugin.remote_plugin_id = Some(remote_plugin_id.to_string()); + let plugin = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_plugin_id_with_remote_id(&plugin_id, remote_plugin_id); self.analytics_events_client .track_plugin_install_failed(plugin, error_type.to_string()); } diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 86fc7067c3..992cd62ed3 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2782,17 +2782,18 @@ impl ThreadRequestProcessor { } } } - let response_history_items = (include_turns - || (initial_turns_page.is_some() && initial_turns_recent_items.is_none())) - .then(|| { + let mut response_history_items_storage = if include_turns + || (initial_turns_page.is_some() && initial_turns_recent_items.is_none()) + { if matches!(history_extent, ResumeHistoryExtent::CompactedCheckpoint) { None } else { - Some(thread_history.get_rollout_items()) + Some(thread_history.get_rollout_items().to_vec()) } - }) - .flatten(); - let response_history_items = if response_history_items.is_none() + } else { + None + }; + if response_history_items_storage.is_none() && initial_turns_page.is_some() && initial_turns_recent_items.is_none() { @@ -2800,15 +2801,14 @@ impl ThreadRequestProcessor { .load_full_resume_history_items(&thread_id, path.as_ref()) .await { - Ok(items) => Some(items), + Ok(items) => response_history_items_storage = Some(items), Err(error) => { self.outgoing.send_error(request_id, error).await; return Ok(()); } } - } else { - response_history_items - }; + } + let response_history_items = response_history_items_storage.as_deref(); match self .thread_manager @@ -2852,7 +2852,7 @@ impl ThreadRequestProcessor { codex_thread.as_ref(), ResumeResponseSource { history: &response_history, - items: response_history_items.as_deref(), + items: response_history_items, }, rollout_path.as_path(), resume_source_thread, @@ -2897,7 +2897,7 @@ impl ThreadRequestProcessor { config_snapshot.active_permission_profile, ); let inline_history_items = if include_turns { - let Some(history_items) = response_history_items.as_deref() else { + let Some(history_items) = response_history_items else { self.outgoing .send_error( request_id, @@ -2927,7 +2927,7 @@ impl ThreadRequestProcessor { } } } else { - let Some(history_items) = response_history_items.as_deref() else { + let Some(history_items) = response_history_items else { self.outgoing .send_error( request_id, @@ -3113,7 +3113,7 @@ impl ThreadRequestProcessor { } }; - if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { + if let Some((existing_thread_id, existing_thread, mut source_thread)) = running_thread { let existing_thread_rollout_path = existing_thread.rollout_path(); let active_path = existing_thread_rollout_path .as_ref() @@ -3175,8 +3175,8 @@ impl ThreadRequestProcessor { should_redact_thread_resume_payloads(app_server_client_name.as_deref()); let history_items = source_thread .history - .as_ref() - .map(|history| history.items.clone()) + .take() + .map(|history| history.items) .ok_or_else(|| { internal_error(format!( "thread {existing_thread_id} did not include persisted history" @@ -3200,10 +3200,8 @@ impl ThreadRequestProcessor { ) .await?; - let mut summary_source_thread = source_thread; - summary_source_thread.history = None; let mut thread_summary = self.stored_thread_to_api_thread( - summary_source_thread, + source_thread, config_snapshot.model_provider_id.as_str(), /*include_turns*/ false, ); @@ -3451,7 +3449,7 @@ impl ThreadRequestProcessor { })?; Ok(InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history, + history: Arc::new(history), rollout_path: stored_thread.rollout_path.clone(), })) } @@ -3668,7 +3666,7 @@ impl ThreadRequestProcessor { let history_items = source_thread .history .take() - .map(|history| history.items) + .map(|history| Arc::new(history.items)) .ok_or_else(|| { internal_error(format!( "thread {source_thread_id} did not include persisted history" diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index db71136ca5..23b79528cd 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -403,7 +403,7 @@ mod thread_processor_behavior_tests { role: role.to_string(), content, phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -412,6 +412,8 @@ mod thread_processor_behavior_tests { message: message.to_string(), replacement_history: Some(vec![compacted_resume_test_message("assistant", message)]), window_number: Some(window_id), + first_window_id: None, + previous_window_id: None, window_id: None, }) } @@ -720,9 +722,7 @@ mod thread_processor_behavior_tests { }, )), compacted_resume_test_user_event("first user event"), - RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::CompactionTrigger { - metadata: None, - }), + RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::CompactionTrigger {}), compacted_resume_test_agent_event("first agent event"), ])?; std::fs::write(&path, contents)?; @@ -1411,6 +1411,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, timestamp: timestamp.clone(), model_provider: None, @@ -1467,6 +1468,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: parent_thread_id.into(), id: conversation_id, timestamp: timestamp.clone(), source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -1517,6 +1519,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: Some(forked_from_id), timestamp: timestamp.clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index f8902e132d..8bead5529b 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -13,6 +13,7 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { let head = vec![ json!({ + "session_id": conversation_id.to_string(), "id": conversation_id.to_string(), "timestamp": timestamp, "cwd": "/", diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 7b5cafe0aa..e0d79a0c3b 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,14 +1,71 @@ use super::*; use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use crate::image_url::REMOTE_IMAGE_URL_ERROR; +use crate::image_url::is_remote_image_url; + const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str = "direct app-server input is not allowed for multi-agent v2 sub-agents"; +fn validate_user_input_image_urls(input: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { + if input.iter().any(|item| { + matches!( + item, + V2UserInput::Image { url, .. } if is_remote_image_url(url) + ) + }) { + return Err(invalid_request(REMOTE_IMAGE_URL_ERROR)); + } + Ok(()) +} + +fn validate_response_item_image_urls(items: &[ResponseItem]) -> Result<(), JSONRPCErrorError> { + if items.iter().any(|item| match item { + ResponseItem::Message { content, .. } => content.iter().any(|item| { + matches!( + item, + ContentItem::InputImage { image_url, .. } if is_remote_image_url(image_url) + ) + }), + ResponseItem::FunctionCallOutput { output, .. } + | ResponseItem::CustomToolCallOutput { output, .. } => { + output.content_items().is_some_and(|content| { + content.iter().any(|item| { + matches!( + item, + FunctionCallOutputContentItem::InputImage { image_url, .. } + if is_remote_image_url(image_url) + ) + }) + }) + } + ResponseItem::Reasoning { .. } + | ResponseItem::AgentMessage { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::ContextCompaction { .. } + | ResponseItem::AdditionalTools { .. } + | ResponseItem::Other => false, + }) { + return Err(invalid_request(REMOTE_IMAGE_URL_ERROR)); + } + Ok(()) +} + #[derive(Clone)] pub(crate) struct TurnRequestProcessor { auth_manager: Arc, @@ -105,6 +162,7 @@ impl TurnRequestProcessor { app_server_client_version: Option, supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { + validate_user_input_image_urls(¶ms.input)?; self.turn_start_inner( request_id, params, @@ -140,6 +198,7 @@ impl TurnRequestProcessor { request_id: &ConnectionRequestId, params: TurnSteerParams, ) -> Result, JSONRPCErrorError> { + validate_user_input_image_urls(¶ms.input)?; self.turn_steer_inner(request_id, params) .await .map(|response| Some(response.into())) @@ -764,6 +823,7 @@ impl TurnRequestProcessor { }) .collect::, _>>() .map_err(invalid_request)?; + validate_response_item_image_urls(&items)?; thread .inject_response_items(items) @@ -1177,7 +1237,7 @@ impl TurnRequestProcessor { config.clone(), InitialHistory::Resumed(ResumedHistory { conversation_id: parent_thread_id, - history: parent_history.items, + history: Arc::new(parent_history.items), rollout_path: parent_thread.rollout_path(), }), /*thread_source*/ None, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6d446e1c42..7b389e8fc1 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo; @@ -127,11 +128,12 @@ pub fn create_fake_rollout_with_source( model_provider, git_info, source, + /*session_id*/ None, /*parent_thread_id*/ None, ) } -/// Create a minimal rollout file with an explicit session source and control parent. +/// Create a minimal rollout file with an explicit root session and control parent. #[allow(clippy::too_many_arguments)] pub fn create_fake_parented_rollout_with_source( codex_home: &Path, @@ -141,6 +143,7 @@ pub fn create_fake_parented_rollout_with_source( model_provider: Option<&str>, git_info: Option, source: SessionSource, + session_id: SessionId, parent_thread_id: ThreadId, ) -> Result { create_fake_rollout_with_source_and_parent_thread_id( @@ -151,6 +154,7 @@ pub fn create_fake_parented_rollout_with_source( model_provider, git_info, source, + Some(session_id), Some(parent_thread_id), ) } @@ -164,11 +168,13 @@ fn create_fake_rollout_with_source_and_parent_thread_id( model_provider: Option<&str>, git_info: Option, source: SessionSource, + session_id: Option, parent_thread_id: Option, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); let conversation_id = ThreadId::from_string(&uuid_str)?; + let session_id = session_id.unwrap_or_else(|| conversation_id.into()); let file_path = rollout_path(codex_home, filename_ts, &uuid_str); let dir = file_path @@ -178,6 +184,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( // Build JSONL lines let meta = SessionMeta { + session_id, id: conversation_id, forked_from_id: None, parent_thread_id, @@ -264,6 +271,7 @@ pub fn create_fake_rollout_with_text_elements( // Build JSONL lines let meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 6e88866c7d..e49e841f65 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -5,7 +5,10 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use chrono::Duration; use chrono::Utc; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; @@ -14,6 +17,7 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -162,7 +166,7 @@ async fn get_auth_status_with_api_key() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { +async fn personal_access_token_without_email_supports_auth_status_and_account_read() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; @@ -171,7 +175,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> .and(path("/v1/user-auth-credential/whoami")) .and(header("Authorization", "Bearer at-test-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "email": "user@example.com", + "email": null, "chatgpt_user_id": "user-123", "chatgpt_account_id": "account-123", "chatgpt_plan_type": "pro", @@ -215,6 +219,34 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> } ); + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + response + .result + .get("account") + .and_then(|account| account.get("email")), + Some(&serde_json::Value::Null), + ); + assert_eq!( + to_response::(response)?, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + server.verify().await; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 80fea41e58..350e0b3c53 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -121,6 +121,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000125")?; store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 06874c4230..3cd98e2e52 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -320,7 +320,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -388,7 +388,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1817,7 +1817,7 @@ async fn get_account_with_chatgpt() -> Result<()> { let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1826,6 +1826,51 @@ async fn get_account_with_chatgpt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn get_account_with_chatgpt_without_email() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(response)?; + + assert_eq!( + received, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + Ok(()) +} + #[tokio::test] async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> { let codex_home = TempDir::new()?; @@ -1944,7 +1989,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Unknown, }), requires_openai_auth: true, diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 6b95c036d0..ffe7a53627 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -63,7 +63,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::Feature("automation".to_string())), + ..Default::default() + }) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -75,7 +78,6 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result let client_metadata = HashMap::from([ ("fiber_run_id".to_string(), "fiber-start-123".to_string()), ("origin".to_string(), "gaas".to_string()), - ("thread_source".to_string(), "client-supplied".to_string()), ]); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -110,7 +112,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result .expect("x-codex-turn-metadata header should be present"); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); - assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied")); + assert_eq!(metadata["thread_source"].as_str(), Some("automation")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("installation_id").is_some()); assert!(metadata.get("session_id").is_some()); @@ -300,7 +302,7 @@ async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() } #[tokio::test] -async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { +async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -321,6 +323,8 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - /*supports_websockets*/ false, )?; + let root_thread_id = CoreThreadId::new(); + let root_thread_id_str = root_thread_id.to_string(); let parent_thread_id = CoreThreadId::new(); let parent_thread_id_str = parent_thread_id.to_string(); let subagent_thread_id = create_fake_parented_rollout_with_source( @@ -331,6 +335,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - Some("mock_provider"), /*git_info*/ None, SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())), + root_thread_id.into(), parent_thread_id, )?; @@ -350,6 +355,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.id, subagent_thread_id); + assert_eq!(thread.session_id, root_thread_id_str); assert_eq!(thread.parent_thread_id, Some(parent_thread_id_str.clone())); assert_eq!( thread.source, @@ -390,6 +396,10 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - Some(parent_thread_id_str.as_str()) ); assert_eq!(metadata["subagent_kind"].as_str(), Some("guardian")); + assert_eq!( + metadata["session_id"].as_str(), + Some(thread.session_id.as_str()) + ); assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("forked_from_thread_id").is_none()); @@ -553,7 +563,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::Feature("automation".to_string())), + ..Default::default() + }) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -611,6 +624,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body .expect("websocket x-codex-turn-metadata client metadata should be present"); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); + assert_eq!(metadata["thread_source"].as_str(), Some("automation")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); assert_eq!( diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 092c64505f..bca34d360d 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -134,12 +134,12 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index ef1cf86753..1336a29e3d 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -38,6 +38,10 @@ use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const REMOTE_IMAGE_URL_ERROR: &str = + "remote image URLs are not supported; use an inline data URL instead"; + // macOS and Windows Bazel CI can spend tens of seconds starting app-server // subprocesses or processing test RPCs under load. #[cfg(any(target_os = "macos", windows))] @@ -550,15 +554,19 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res Ok(()) } -/// Ensures dynamic tool call responses can include structured content items. -#[tokio::test] -async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> { - let call_id = "dyn-call-items-1"; +struct PendingDynamicToolCall { + mcp: TestAppServer, + server: MockServer, + request_id: RequestId, + params: DynamicToolCallParams, +} + +async fn start_function_dynamic_tool_call(call_id: &str) -> Result { let tool_name = "demo_tool"; let tool_args = json!({ "city": "Paris" }); let tool_call_arguments = serde_json::to_string(&tool_args)?; - let responses = vec![ + let response_sequence = vec![ responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, tool_name, &tool_call_arguments), @@ -566,7 +574,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( ]), create_final_assistant_message_sse_response("Done")?, ]; - let server = create_mock_responses_server_sequence_unchecked(responses).await; + let server = create_mock_responses_server_sequence_unchecked(response_sequence).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -630,27 +638,46 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( mcp.read_stream_until_request_message(), ) .await??; - let (request_id, params) = match request { + let (request_id, actual_params) = match request { ServerRequest::DynamicToolCall { request_id, params } => (request_id, params), other => panic!("expected DynamicToolCall request, got {other:?}"), }; - let expected = DynamicToolCallParams { + let params = DynamicToolCallParams { thread_id, - turn_id: turn_id.clone(), + turn_id, call_id: call_id.to_string(), namespace: None, tool: tool_name.to_string(), arguments: tool_args, }; - assert_eq!(params, expected); + assert_eq!(actual_params, params); + + Ok(PendingDynamicToolCall { + mcp, + server, + request_id, + params, + }) +} + +/// Ensures dynamic tool call responses can include structured content items. +#[tokio::test] +async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> { + let call_id = "dyn-call-items-1"; + let PendingDynamicToolCall { + mut mcp, + server, + request_id, + params, + } = start_function_dynamic_tool_call(call_id).await?; let response_content_items = vec![ DynamicToolCallOutputContentItem::InputText { text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]; let content_items = response_content_items @@ -676,8 +703,8 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( .await?; let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; - assert_eq!(completed.thread_id, expected.thread_id.clone()); - assert_eq!(completed.turn_id, turn_id); + assert_eq!(completed.thread_id, params.thread_id); + assert_eq!(completed.turn_id, params.turn_id); let ThreadItem::DynamicToolCall { status, content_items: completed_content_items, @@ -695,7 +722,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]) ); @@ -721,7 +748,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( }, { "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": TINY_PNG_DATA_URL, "detail": "high" } ]) @@ -744,6 +771,62 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( Ok(()) } +#[tokio::test] +async fn dynamic_tool_remote_image_response_becomes_model_visible_error() -> Result<()> { + let call_id = "dyn-call-remote-image"; + let PendingDynamicToolCall { + mut mcp, + server, + request_id, + params, + } = start_function_dynamic_tool_call(call_id).await?; + + let response = DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputImage { + image_url: "https://example.com/tool.png".to_string(), + }], + success: true, + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + + let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; + assert_eq!(completed.thread_id, params.thread_id); + assert_eq!(completed.turn_id, params.turn_id); + let ThreadItem::DynamicToolCall { + status, + content_items, + success, + .. + } = completed.item + else { + panic!("expected dynamic tool call item"); + }; + assert_eq!(status, DynamicToolCallStatus::Failed); + assert_eq!( + content_items, + Some(vec![DynamicToolCallOutputContentItem::InputText { + text: REMOTE_IMAGE_URL_ERROR.to_string(), + }]) + ); + assert_eq!(success, Some(false)); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let output = responses_bodies(&server) + .await? + .iter() + .find_map(|body| function_call_output_raw_output(body, call_id)) + .context("expected function_call_output output in follow-up request")?; + assert_eq!(output, json!(REMOTE_IMAGE_URL_ERROR)); + + Ok(()) +} + async fn responses_bodies(server: &MockServer) -> Result> { let requests = server .received_requests() diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 5c18aa8a4e..23b97dc9c5 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -28,13 +28,13 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -const RESULT: &str = "cG5n"; +const RESULT: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; 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, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; -const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; #[derive(Clone, Copy)] enum ImagegenTestMode { @@ -116,7 +116,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul assert_eq!(status, "completed"); assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale")); assert_eq!(result, RESULT); - assert_eq!(std::fs::read(&saved_path)?, b"png"); + assert_eq!(std::fs::read(&saved_path)?, TINY_PNG_BYTES); let requests = response_mock.requests(); assert_eq!(requests.len(), 2); @@ -256,7 +256,7 @@ async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()> #[tokio::test] async fn standalone_image_edit_uses_recent_pathless_image() -> Result<()> { - let image_url = "https://example.com/reference.png"; + let image_url = TINY_PNG_DATA_URL; let edit_request = run_image_edit_test(|_| { Ok(( json!({ diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index f4400da468..c777142e82 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -52,6 +52,7 @@ mod remote_control; mod remote_thread_store; mod request_permissions; mod request_user_input; +mod request_validation; mod review; mod safety_check_downgrade; mod skills_list; diff --git a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs index 63e92af578..5d02cafa0a 100644 --- a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs +++ b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs @@ -60,22 +60,27 @@ description = "Inspect without writes." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: "audit".to_string(), description: Some("Inspect without writes.".to_string()), + allowed: true, }, PermissionProfileSummary { id: "dev".to_string(), description: Some("Day-to-day coding work.".to_string()), + allowed: true, }, ], next_cursor: None, @@ -126,14 +131,17 @@ description = "Project-scoped profile." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, ], next_cursor: Some("3".to_string()), @@ -155,6 +163,7 @@ description = "Project-scoped profile." data: vec![PermissionProfileSummary { id: "project".to_string(), description: Some("Project-scoped profile.".to_string()), + allowed: true, }], next_cursor: None, } @@ -200,18 +209,22 @@ description = "Project-scoped profile." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: "project".to_string(), description: Some("Project-scoped profile.".to_string()), + allowed: true, }, ], next_cursor: None, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 62a17278f2..4d143109fa 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -753,7 +753,9 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), }), @@ -1761,6 +1763,14 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) .mount(&server) .await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated-remote"]"#), + ) + .mount(&server) + .await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -1854,7 +1864,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - cached_plugin_ids, vec!["plugins~Plugin_00000000000000000000000000000000".to_string()] ); - assert_eq!(response.featured_plugin_ids, Vec::::new()); + assert_eq!( + response.featured_plugin_ids, + vec!["linear@openai-curated-remote".to_string()] + ); assert!( !server .received_requests() diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 5009beb640..9d7b7902b6 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -162,7 +162,8 @@ apps = true "short_description": "Example plugin", "capabilities": [], "default_prompt": "Use the legacy example prompt", - "default_prompts": [] + "default_prompts": [], + "logo_url_dark": "https://example.com/example-plugin-dark.png" }, "skills": [], "mcp_servers": [ @@ -273,6 +274,15 @@ apps = true .and_then(|interface| interface.default_prompt.clone()), Some(vec!["Use the legacy example prompt".to_string()]) ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.logo_url_dark.as_deref()), + Some("https://example.com/example-plugin-dark.png") + ); assert_eq!( response.plugin.mcp_servers, vec!["other-server".to_string()] @@ -1330,6 +1340,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> "brandColor": "#3B82F6", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", "screenshots": ["./assets/screenshot1.png"] } }"##, @@ -1508,6 +1519,18 @@ enabled = false "Find my next action".to_string() ]) ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.logo_dark.as_ref()), + Some( + &AbsolutePathBuf::try_from(plugin_root.join("assets/logo-dark.png")) + .expect("absolute dark logo path") + ) + ); assert_eq!( response.plugin.summary.keywords, vec!["api-key".to_string(), "developer tools".to_string()] diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 99fc1289d4..ba142feb90 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -1357,7 +1357,9 @@ fn expected_plugin_interface() -> PluginInterface { composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), } diff --git a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs index d45dcdc3a5..86ea09504c 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs @@ -204,12 +204,11 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> { "rate limit reset consume timed out" ); - let account_error = read_error_response(&mut mcp, account_id).await?; - assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - account_error.error.message, - "email and plan type are required for chatgpt authentication" - ); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(account_id)), + ) + .await??; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 4fba9d178b..505cbdafef 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -147,6 +147,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; thread_store .create_thread(StoreCreateThreadParams { + session_id: unloaded_thread_id.into(), thread_id: unloaded_thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/request_validation.rs b/codex-rs/app-server/tests/suite/v2/request_validation.rs new file mode 100644 index 0000000000..c4382e63c2 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/request_validation.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ImageDetail; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_IMAGE_URL_ERROR: &str = + "remote image URLs are not supported; use an inline data URL instead"; + +#[tokio::test] +async fn request_handlers_reject_remote_image_urls() -> Result<()> { + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + "http://localhost/unused", + "http://localhost/unused", + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_response)?; + let thread_id = thread.id; + + let remote_tool_output = serde_json::to_value(ResponseItem::FunctionCallOutput { + id: None, + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/tool.png".to_string(), + detail: Some(ImageDetail::High), + }, + ]), + internal_chat_message_metadata_passthrough: None, + })?; + let requests = [ + ( + "turn/start", + json!({ + "threadId": thread_id, + "input": [{ + "type": "image", + "url": "HTTP://example.com/start.png", + "detail": "high" + }] + }), + ), + ( + "turn/steer", + json!({ + "threadId": thread_id, + "expectedTurnId": "turn-id", + "input": [{ + "type": "image", + "url": "https://example.com/steer.png", + "detail": "high" + }] + }), + ), + ( + "thread/inject_items", + json!({ + "threadId": thread_id, + "items": [remote_tool_output] + }), + ), + ]; + + for (method, params) in requests { + let request_id = mcp.send_raw_request(method, Some(params)).await?; + let actual: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + let expected = JSONRPCError { + id: RequestId::Integer(request_id), + error: JSONRPCErrorError { + code: -32600, + data: None, + message: REMOTE_IMAGE_URL_ERROR.to_string(), + }, + }; + assert_eq!(actual, expected, "unexpected response for {method}"); + } + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 21dae50d6f..aa4908a378 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -60,7 +60,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu text: injected_text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let inject_req = mcp @@ -86,7 +86,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu resumed_history .history .iter() - .any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if response_item == &injected_item)), + .any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if responses::strip_metadata(response_item.clone()) == injected_item)), "injected item should be persisted in rollout history" ); @@ -198,7 +198,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() text: "Injected after first turn".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let injected_value = serde_json::to_value(&injected_item)?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 9bdff3bb8b..9779513024 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1252,6 +1252,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { Some("mock_provider"), /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Review), + parent_thread_id.into(), parent_thread_id, )?; let compact_id = create_fake_rollout_with_source( diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index df6246bd53..eeb1f713d5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1357,6 +1357,7 @@ async fn seed_pathless_store_thread( ) -> Result<()> { store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, 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 07ee66caf2..ea7a1a9d46 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2054,6 +2054,7 @@ stream_max_retries = 0 let rollout_dir = rollout_path.parent().expect("rollout parent directory"); std::fs::create_dir_all(rollout_dir)?; let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, @@ -2588,7 +2589,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { text: "history override".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]), ..Default::default() }) @@ -2735,6 +2736,7 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result "timestamp": "2025-01-01T00:00:00Z", "type": "session_meta", "payload": { + "session_id": thread_uuid, "id": thread_uuid, "timestamp": "2025-01-01T00:00:00Z", "cwd": codex_home.path(), @@ -3596,7 +3598,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { text: history_text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; // Resume with explicit history and override the model. diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index efdf13aae9..9ad25ca992 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -117,10 +117,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re write_mock_responses_config_toml( codex_home.path(), &server.uri(), - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), /*auto_compact_limit*/ 200_000, /*requires_openai_auth*/ None, "mock_provider", @@ -158,7 +155,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re assert_eq!(updated.thread_id, thread.id); assert_eq!( updated.thread_settings.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); start_text_turn(&mut mcp, thread.id).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 4b2c210173..68e9a88a44 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -208,6 +208,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { let parent_thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000127")?; store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: Some(parent_thread_id), 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 10d1599967..622e4284ef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -100,12 +100,14 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; 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, ]; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; fn body_contains(req: &wiremock::Request, text: &str) -> bool { String::from_utf8(req.body.clone()) @@ -888,7 +890,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![V2UserInput::Image { - url: "https://example.com/a.png".to_string(), + url: TINY_PNG_DATA_URL.to_string(), detail: None, }], responsesapi_client_metadata: Some(HashMap::from([( @@ -1768,10 +1770,7 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -1845,10 +1844,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -1871,7 +1867,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { multi_agent_mode, .. } = to_response::(thread_resp)?; - assert_eq!(multi_agent_mode, Some(MultiAgentMode::Proactive)); + assert_eq!(multi_agent_mode, MultiAgentMode::Proactive); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -1911,27 +1907,29 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { } #[tokio::test] -async fn thread_start_reports_selected_multi_agent_mode() -> Result<()> { +async fn thread_start_reports_multi_agent_mode() -> Result<()> { skip_if_no_network!(Ok(())); let cases = [ ( BTreeMap::from([(Feature::MultiAgentV2, true)]), Some(MultiAgentMode::Proactive), - Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, + ), + ( + BTreeMap::from([(Feature::MultiAgentV2, true)]), + Some(MultiAgentMode::None), + MultiAgentMode::None, ), ( BTreeMap::new(), Some(MultiAgentMode::Proactive), - Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, ), ( - BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), - None, + BTreeMap::from([(Feature::MultiAgentV2, true)]), None, + MultiAgentMode::ExplicitRequestOnly, ), ]; @@ -3653,7 +3651,12 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> { |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), responses::sse(vec![ responses::ev_response_created("resp-parent-1"), - responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), responses::ev_completed("resp-parent-1"), ]), ) diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 626767132b..f8c115d746 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -156,8 +156,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { search_body["input"] .as_array() .context("search input should be an array")? - .last(), - Some(&json!({ + .last() + .cloned() + .map(responses::strip_metadata_from_json), + Some(json!({ "type": "message", "role": "user", "content": [{"type": "input_text", "text": "Search the web"}], @@ -165,7 +167,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { ); assert_eq!( - requests[1].function_call_output(call_id), + responses::strip_metadata_from_json(requests[1].function_call_output(call_id)), json!({ "type": "function_call_output", "call_id": call_id, diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index de9c735c59..8ac2084e4f 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -500,7 +500,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -517,7 +517,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -752,7 +752,7 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -792,7 +792,7 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -832,7 +832,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &PathUri::from_path(session_dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -844,7 +844,7 @@ PATCH"#, result, MaybeApplyPatchVerified::Body(ApplyPatchAction { changes: HashMap::from([( - PathUri::from_path(session_dir.path().join(relative_path)) + PathUri::from_host_native_path(session_dir.path().join(relative_path)) .expect("absolute test path"), ApplyPatchFileChange::Update { unified_diff: r#"@@ -1 +1 @@ @@ -857,7 +857,8 @@ PATCH"#, }, )]), patch: argv[1].clone(), - cwd: PathUri::from_path(session_dir.path()).expect("absolute test path"), + cwd: PathUri::from_host_native_path(session_dir.path()) + .expect("absolute test path"), }) ); } @@ -887,7 +888,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &PathUri::from_path(session_dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -902,8 +903,8 @@ PATCH"#, worktree_dir.as_path() ); - let source_path = - PathUri::from_path(worktree_dir.join(source_name)).expect("absolute test path"); + let source_path = PathUri::from_host_native_path(worktree_dir.join(source_name)) + .expect("absolute test path"); let change = action .changes() .get(&source_path) @@ -912,7 +913,8 @@ PATCH"#, match change { ApplyPatchFileChange::Update { move_path, .. } => { let expected_move_path = - PathUri::from_path(worktree_dir.join(dest_name)).expect("absolute test path"); + PathUri::from_host_native_path(worktree_dir.join(dest_name)) + .expect("absolute test path"); assert_eq!(move_path.as_ref(), Some(&expected_move_path)); } other => panic!("expected update change, got {other:?}"), @@ -923,7 +925,7 @@ PATCH"#, async fn test_unreadable_destinations_still_verify() { let session_dir = tempdir().unwrap(); fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap(); - let cwd = PathUri::from_path(session_dir.path()).expect("absolute test path"); + let cwd = PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"); let add_argv = vec![ "apply_patch".to_string(), "*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(), @@ -966,7 +968,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &PathUri::from_path(session_dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index f27610d6cc..81ad1f0781 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -913,7 +913,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -937,7 +937,7 @@ mod tests { #[tokio::test] async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() { let dir = tempdir().unwrap(); - let cwd = PathUri::from_path(dir.path()).expect("absolute test path"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path"); let relative_add = dir.path().join("relative-add.txt"); let absolute_add = dir.path().join("absolute-add.txt"); let relative_delete = dir.path().join("relative-delete.txt"); @@ -1016,7 +1016,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1052,7 +1052,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1092,7 +1092,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1136,7 +1136,7 @@ mod tests { let mut stderr = Vec::new(); let failure = apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1196,7 +1196,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1254,7 +1254,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1298,7 +1298,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1341,7 +1341,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1391,7 +1391,7 @@ mod tests { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( &path_uri, update_file_chunks, @@ -1439,7 +1439,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( &resolved_path, chunks, @@ -1485,7 +1485,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( &resolved_path, chunks, @@ -1529,7 +1529,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -1580,7 +1580,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -1610,7 +1610,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1648,7 +1648,7 @@ g let mut stderr = Vec::new(); let result = apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1667,7 +1667,7 @@ g let dir = tempdir().unwrap(); let path = dir.path().join("binary.dat"); fs::write(dir.path().join("source.txt"), "before\n").unwrap(); - let cwd = PathUri::from_path(dir.path()).expect("absolute test path"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path"); for patch in [ wrap_patch("*** Add File: binary.dat\n+text"), @@ -1705,7 +1705,7 @@ g let mut stderr = Vec::new(); let delta = apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index 04e0053a3a..ec2d97c477 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -480,7 +480,7 @@ fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() { #[test] fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { let cwd_dir = tempfile::tempdir().unwrap(); - let cwd = PathUri::from_path(cwd_dir.path()).unwrap(); + let cwd = PathUri::from_host_native_path(cwd_dir.path()).unwrap(); let absolute_dir = tempfile::tempdir().unwrap(); let absolute_add = absolute_dir.path().join("absolute-add.py").abs(); let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs(); diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e8e4f5fb52..be444b6247 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,5 +1,6 @@ use crate::types::AccountsCheckResponse; use crate::types::CodeTaskDetailsResponse; +use crate::types::CodexWorkspaceMessagesResponse; use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; @@ -19,6 +20,7 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::SpendControlLimitSnapshot; use reqwest::StatusCode; +use reqwest::header::CACHE_CONTROL; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; @@ -430,6 +432,20 @@ impl Client { .map_err(RequestError::from) } + pub async fn list_workspace_messages( + &self, + ) -> std::result::Result { + let url = self.workspace_messages_url(); + let req = self + .http + .get(&url) + .headers(self.headers()) + .header(CACHE_CONTROL, HeaderValue::from_static("no-store")); + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + .map_err(RequestError::from) + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { @@ -570,6 +586,13 @@ impl Client { } } + fn workspace_messages_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url), + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -936,6 +959,21 @@ mod tests { ); } + #[test] + fn workspace_messages_uses_expected_paths() { + let codex_client = test_client("https://example.test", PathStyle::CodexApi); + assert_eq!( + codex_client.workspace_messages_url(), + "https://example.test/api/codex/workspace-messages" + ); + + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); + assert_eq!( + chatgpt_client.workspace_messages_url(), + "https://chatgpt.com/backend-api/wham/workspace-messages" + ); + } + fn test_client(base_url: &str, path_style: PathStyle) -> Client { Client { base_url: base_url.to_string(), diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 9731bc82b3..6cbeaf30b2 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -8,6 +8,9 @@ pub use types::AccountEntry; pub use types::AccountsCheckResponse; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; +pub use types::CodexWorkspaceMessage; +pub use types::CodexWorkspaceMessageType; +pub use types::CodexWorkspaceMessagesResponse; pub use types::ConfigBundleResponse; pub use types::ConsumeRateLimitResetCreditCode; pub use types::ConsumeRateLimitResetCreditResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index d469713891..18ab323986 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -36,6 +36,23 @@ pub(crate) struct RateLimitStatusWithResetCredits { pub rate_limit_reset_credits: Option, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessagesResponse { + #[serde(default)] + pub messages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessage { + pub message_id: String, + pub message_type: CodexWorkspaceMessageType, + pub message_body: String, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub archived_at: Option, +} + #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConsumeRateLimitResetCreditCode { @@ -52,6 +69,15 @@ pub struct ConsumeRateLimitResetCreditResponse { pub windows_reset: i64, } +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexWorkspaceMessageType { + Headline, + Announcement, + #[serde(other)] + Unknown, +} + #[derive(Clone, Debug)] pub struct AccountsCheckResponse { pub accounts: Vec, @@ -520,4 +546,61 @@ Second line" .expect("error should be present"); assert_eq!(msg, "APPLY_FAILED: Patch could not be applied"); } + + #[test] + fn workspace_messages_response_deserializes_messages() { + let response: CodexWorkspaceMessagesResponse = serde_json::from_value(serde_json::json!({ + "messages": [ + { + "message_id": "headline-id", + "message_type": "headline", + "message_body": "Headline body", + "created_at": "2026-06-14T00:00:00Z", + "archived_at": null + }, + { + "message_id": "announcement-id", + "message_type": "announcement", + "message_body": "Announcement body", + "created_at": "2026-06-14T01:00:00Z", + "archived_at": null + }, + { + "message_id": "unknown-id", + "message_type": "unknown", + "message_body": "Unknown body" + } + ] + })) + .expect("workspace messages response should deserialize"); + + assert_eq!( + response, + CodexWorkspaceMessagesResponse { + messages: vec![ + CodexWorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: CodexWorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some("2026-06-14T00:00:00Z".to_string()), + archived_at: None, + }, + CodexWorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: CodexWorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: Some("2026-06-14T01:00:00Z".to_string()), + archived_at: None, + }, + CodexWorkspaceMessage { + message_id: "unknown-id".to_string(), + message_type: CodexWorkspaceMessageType::Unknown, + message_body: "Unknown body".to_string(), + created_at: None, + archived_at: None, + }, + ], + } + ); + } } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 5e5bcb6c4e..dd2d646aa6 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -6,6 +6,7 @@ mod seatbelt; use std::path::PathBuf; use std::process::Stdio; +use anyhow::Context as _; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -16,6 +17,8 @@ use codex_core::exec_env::create_env; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::SandboxEnforcement; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile; @@ -45,6 +48,7 @@ pub async fn run_command_under_seatbelt( loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let SeatbeltCommand { + sandbox_state, permissions_profile, config_profile: _, cwd, @@ -60,6 +64,7 @@ pub async fn run_command_under_seatbelt( ); run_command_under_sandbox( DebugSandboxConfigOptions { + sandbox_state, permissions_profile, cwd, managed_requirements_mode, @@ -90,6 +95,7 @@ pub async fn run_command_under_landlock( loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let LandlockCommand { + sandbox_state, permissions_profile, config_profile: _, cwd, @@ -103,6 +109,7 @@ pub async fn run_command_under_landlock( ); run_command_under_sandbox( DebugSandboxConfigOptions { + sandbox_state, permissions_profile, cwd, managed_requirements_mode, @@ -124,6 +131,7 @@ pub async fn run_command_under_windows_sandbox( loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let WindowsCommand { + sandbox_state, permissions_profile, config_profile: _, cwd, @@ -137,6 +145,7 @@ pub async fn run_command_under_windows_sandbox( ); run_command_under_sandbox( DebugSandboxConfigOptions { + sandbox_state, permissions_profile, cwd, managed_requirements_mode, @@ -161,6 +170,7 @@ enum SandboxType { #[derive(Debug)] struct DebugSandboxConfigOptions { + sandbox_state: crate::SandboxStateArgs, permissions_profile: Option, cwd: Option, managed_requirements_mode: ManagedRequirementsMode, @@ -187,7 +197,7 @@ impl ManagedRequirementsMode { } async fn run_command_under_sandbox( - config_options: DebugSandboxConfigOptions, + mut config_options: DebugSandboxConfigOptions, command: Vec, config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, @@ -196,6 +206,34 @@ async fn run_command_under_sandbox( #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] allow_unix_sockets: &[AbsolutePathBuf], ) -> anyhow::Result<()> { + let sandbox_state = config_options + .sandbox_state + .sandbox_state_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|err| anyhow::anyhow!("invalid --sandbox-state-json value: {err}"))?; + let sandbox_state_readable_root = config_options + .sandbox_state + .sandbox_state_readable_root + .clone(); + let sandbox_state_disable_network = config_options.sandbox_state.sandbox_state_disable_network; + let codex_linux_sandbox_exe = match sandbox_state.as_ref() { + Some(state) => { + config_options.cwd = Some( + state + .sandbox_cwd + .to_abs_path() + .context("sandbox state cwd is not native to this host")? + .to_path_buf(), + ); + state + .codex_linux_sandbox_exe + .clone() + .or(codex_linux_sandbox_exe) + } + None => codex_linux_sandbox_exe, + }; let config = load_debug_sandbox_config( config_overrides .parse_overrides() @@ -220,12 +258,75 @@ async fn run_command_under_sandbox( &config.permissions.shell_environment_policy, /*thread_id*/ None, ); + let mut permission_profile = match sandbox_state.as_ref() { + Some(state) => match &state.permission_profile { + PermissionProfile::External { .. } => { + // `External` only says that the producer relies on an outer sandbox; it does not + // include filesystem permissions we can recreate here. The consumer may not share + // that sandbox, so use a locally enforceable read-only profile instead of spawning + // without a sandbox. + PermissionProfile::read_only() + } + permission_profile => permission_profile.clone(), + }, + None => config.permissions.effective_permission_profile(), + }; + if matches!(permission_profile, PermissionProfile::Disabled) && sandbox_state_disable_network { + anyhow::bail!( + "--sandbox-state-disable-network cannot be applied to a disabled permission profile" + ); + } + if !matches!(permission_profile, PermissionProfile::Disabled) + && (!sandbox_state_readable_root.is_empty() || sandbox_state_disable_network) + { + let file_system = permission_profile + .file_system_sandbox_policy() + .with_additional_readable_roots(&cwd, &sandbox_state_readable_root); + let network = if sandbox_state_disable_network { + NetworkSandboxPolicy::Restricted + } else { + permission_profile.network_sandbox_policy() + }; + permission_profile = PermissionProfile::from_runtime_permissions(&file_system, network); + } + let use_legacy_landlock = sandbox_state.as_ref().map_or_else( + || config.features.use_legacy_landlock(), + |state| state.use_legacy_landlock, + ); + + match permission_profile.enforcement() { + SandboxEnforcement::Managed => {} + SandboxEnforcement::Disabled | SandboxEnforcement::External => { + let (program, args) = command + .split_first() + .context("sandbox command must not be empty")?; + let mut child = spawn_debug_sandbox_child( + PathBuf::from(program), + args.to_vec(), + /*arg0*/ None, + cwd.to_path_buf(), + permission_profile.network_sandbox_policy(), + env, + |_| {}, + ) + .await?; + handle_exit_status(child.wait().await?); + } + } // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - run_command_under_windows_session(&config, command, cwd, workspace_roots, env).await; + run_command_under_windows_session( + &config, + &permission_profile, + command, + cwd, + workspace_roots, + env, + ) + .await; } #[cfg(not(target_os = "windows"))] { @@ -244,7 +345,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile(), + &permission_profile, /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -266,7 +367,7 @@ async fn run_command_under_sandbox( None => None, }; let runtime_permission_profile = with_managed_mitm_ca_readable_root( - config.permissions.effective_permission_profile(), + permission_profile, managed_mitm_ca_trust_bundle_path.as_ref(), sandbox_policy_cwd.as_path(), ); @@ -308,7 +409,6 @@ async fn run_command_under_sandbox( let codex_linux_sandbox_exe = config .codex_linux_sandbox_exe .expect("codex-linux-sandbox executable not found"); - let use_legacy_landlock = config.features.use_legacy_landlock(); let network_sandbox_policy = runtime_permission_profile.network_sandbox_policy(); let args = create_linux_sandbox_command_args_for_permission_profile( command, @@ -364,6 +464,7 @@ async fn run_command_under_sandbox( #[cfg(target_os = "windows")] async fn run_command_under_windows_session( config: &Config, + permission_profile: &PermissionProfile, command: Vec, cwd: AbsolutePathBuf, workspace_roots: Vec, @@ -374,10 +475,9 @@ async fn run_command_under_windows_session( use codex_windows_sandbox::WindowsSandboxSessionRequest; use codex_windows_sandbox::spawn_windows_sandbox_session_for_level; - let permission_profile = config.permissions.effective_permission_profile(); let empty_paths: &[AbsolutePathBuf] = &[]; let spawned = spawn_windows_sandbox_session_for_level(WindowsSandboxSessionRequest { - permission_profile: &permission_profile, + permission_profile, workspace_roots: workspace_roots.as_slice(), codex_home: config.codex_home.as_path(), command, @@ -464,6 +564,7 @@ async fn load_debug_sandbox_config_with_codex_home( strict_config: bool, ) -> anyhow::Result { let DebugSandboxConfigOptions { + sandbox_state: _, permissions_profile, cwd, managed_requirements_mode, @@ -649,6 +750,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -717,6 +819,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -774,6 +877,7 @@ mod tests { cli_overrides, /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -832,6 +936,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -859,6 +964,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: Some(":workspace".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, @@ -898,6 +1004,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: Some("limited-read-test".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, @@ -937,6 +1044,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: Some(":workspace".to_string()), cwd: Some(cwd.path().to_path_buf()), managed_requirements_mode: ManagedRequirementsMode::Ignore, diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 5e5a1e94be..5f60a96ff3 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1520,19 +1520,35 @@ async fn mcp_check_from_servers(servers: &HashMap) -> D if disabled_server { continue; } - if let Some(cwd) = cwd - && !cwd.exists() - { - missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display())); - } - if command.trim().is_empty() { + let command_is_empty = command.trim().is_empty(); + if command_is_empty { missing_env.push(format!("{name}: stdio command is empty")); - } else if let Err(err) = - stdio_command_resolves(command, cwd.as_deref(), env.as_ref()) - { - missing_env.push(format!( - "{name}: stdio command {command:?} is not resolvable ({err})" - )); + } + if server.is_local_environment() { + let host_native_cwd = cwd.as_ref().map(|cwd| Path::new(cwd.as_str())); + if let Some(cwd) = host_native_cwd + && !cwd.exists() + { + missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display())); + } + if !command_is_empty + && let Err(err) = + stdio_command_resolves(command, host_native_cwd, env.as_ref()) + { + missing_env.push(format!( + "{name}: stdio command {command:?} is not resolvable ({err})" + )); + } + } else { + match cwd { + Some(cwd) if cwd.to_inferred_path_uri().is_none() => { + missing_env + .push(format!("{name}: remote stdio cwd is not absolute ({cwd})")); + } + None => missing_env + .push(format!("{name}: remote stdio requires an explicit cwd")), + Some(_) => {} + } } if let Some(env) = env { for key in env.keys().filter(|key| key.trim().is_empty()) { @@ -1541,10 +1557,12 @@ async fn mcp_check_from_servers(servers: &HashMap) -> D } for env_var in env_vars { if env_var.is_remote_source() { - missing_env.push(format!( - "{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", - env_var.name() - )); + if server.is_local_environment() { + missing_env.push(format!( + "{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", + env_var.name() + )); + } } else if !env_var_present(env_var.name()) { missing_env.push(format!("{name}: env var {} is not set", env_var.name())); } @@ -3863,6 +3881,70 @@ mod tests { })); } + #[tokio::test] + async fn mcp_check_skips_host_path_checks_for_remote_stdio() { + #[cfg(not(windows))] + let cwd = r"C:\Users\openai\share"; + #[cfg(windows)] + let cwd = "/home/openai/share"; + let cwd = toml::Value::String(cwd.to_string()); + let remote_server: McpServerConfig = toml::from_str(&format!( + r#" + command = "definitely-missing-codex-doctor-mcp" + environment_id = "remote" + cwd = {cwd} + required = true + env_vars = [{{ name = "REMOTE_ONLY_TOKEN", source = "remote" }}] + "#, + )) + .expect("should deserialize remote MCP config"); + let servers = HashMap::from([("remote".to_string(), remote_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "MCP configuration is locally consistent"); + } + + #[tokio::test] + async fn mcp_check_validates_remote_stdio_cwd() { + let missing_cwd: McpServerConfig = toml::from_str( + r#" + command = "echo" + environment_id = "remote" + required = true + "#, + ) + .expect("should deserialize remote MCP config without cwd"); + let relative_cwd: McpServerConfig = toml::from_str( + r#" + command = "echo" + environment_id = "remote" + cwd = "relative" + required = true + "#, + ) + .expect("should deserialize remote MCP config with relative cwd"); + let servers = HashMap::from([ + ("missing".to_string(), missing_cwd), + ("relative".to_string(), relative_cwd), + ]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert!( + check + .details + .contains(&"missing: remote stdio requires an explicit cwd".to_string()) + ); + assert!( + check + .details + .contains(&"relative: remote stdio cwd is not absolute (relative)".to_string()) + ); + } + #[cfg(unix)] #[test] fn read_probe_file_rejects_unreadable_file() { diff --git a/codex-rs/cli/src/doctor/thread_inventory.rs b/codex-rs/cli/src/doctor/thread_inventory.rs index 9a8eef6ee4..c50e6debdc 100644 --- a/codex-rs/cli/src/doctor/thread_inventory.rs +++ b/codex-rs/cli/src/doctor/thread_inventory.rs @@ -816,11 +816,13 @@ mod tests { }; std::fs::create_dir_all(&root).expect("rollout dir"); let path = root.join(format!("rollout-{timestamp}-{thread_id}.jsonl")); + let parsed_thread_id = ThreadId::from_string(thread_id).expect("thread id"); let rollout_line = RolloutLine { timestamp: timestamp.to_string(), item: RolloutItem::SessionMeta(codex_protocol::protocol::SessionMetaLine { meta: codex_protocol::protocol::SessionMeta { - id: ThreadId::from_string(thread_id).expect("thread id"), + session_id: parsed_thread_id.into(), + id: parsed_thread_id, timestamp: timestamp.to_string(), cwd: self.codex_home.path().to_path_buf(), originator: "test".to_string(), diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 4e63df187e..1c0a2e7e02 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -2,6 +2,7 @@ pub(crate) mod debug_sandbox; mod exit_status; pub(crate) mod login; +use clap::Args; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; @@ -21,10 +22,36 @@ pub use login::run_login_with_device_code; pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; +#[derive(Debug, Default, Args)] +pub struct SandboxStateArgs { + /// JSON value from `codex/sandbox-state-meta` to apply directly. + #[arg( + long = "sandbox-state-json", + value_name = "JSON", + conflicts_with_all = ["permissions_profile", "cwd", "include_managed_config"] + )] + pub sandbox_state_json: Option, + + /// Add a readable root to the supplied sandbox state. Repeat for multiple roots. + #[arg( + long, + requires = "sandbox_state_json", + value_parser = parse_absolute_path + )] + pub sandbox_state_readable_root: Vec, + + /// Disable direct network access in the supplied sandbox state. + #[arg(long, requires = "sandbox_state_json", default_value_t = false)] + pub sandbox_state_disable_network: bool, +} + // These command structs share common sandbox options, but remain separate // because each host backend has a slightly different option surface. #[derive(Debug, Parser)] pub struct SeatbeltCommand { + #[command(flatten)] + pub sandbox_state: SandboxStateArgs, + /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, @@ -51,7 +78,7 @@ pub struct SeatbeltCommand { pub include_managed_config: bool, /// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths. - #[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)] + #[arg(long = "allow-unix-socket", value_parser = parse_absolute_path)] pub allow_unix_sockets: Vec, /// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit @@ -66,13 +93,16 @@ pub struct SeatbeltCommand { pub command: Vec, } -fn parse_allow_unix_socket_path(raw: &str) -> Result { +fn parse_absolute_path(raw: &str) -> Result { AbsolutePathBuf::relative_to_current_dir(raw) .map_err(|err| format!("invalid path {raw}: {err}")) } #[derive(Debug, Parser)] pub struct LandlockCommand { + #[command(flatten)] + pub sandbox_state: SandboxStateArgs, + /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, @@ -108,6 +138,9 @@ pub struct LandlockCommand { #[derive(Debug, Parser)] pub struct WindowsCommand { + #[command(flatten)] + pub sandbox_state: SandboxStateArgs, + /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index dc3d0af87d..5691c3cd1f 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_login::AuthKeyringBackendKind; +use codex_login::AuthRouteConfig; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; @@ -119,11 +120,13 @@ async fn clear_existing_auth_before_login( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) { if let Err(err) = logout_with_revoke( codex_home, auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ) .await { @@ -136,11 +139,13 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> std::io::Result<()> { clear_existing_auth_before_login( &codex_home, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config.as_ref(), ) .await; @@ -150,6 +155,7 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ); let server = run_login_server(opts)?; @@ -169,12 +175,12 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - match login_with_chatgpt( config.codex_home.to_path_buf(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await { @@ -232,6 +238,7 @@ pub async fn run_login_with_access_token( std::process::exit(1); } + let auth_route_config = config.auth_route_config(); match login_with_access_token( &config.codex_home, &access_token, @@ -239,6 +246,7 @@ pub async fn run_login_with_access_token( config.forced_chatgpt_workspace_id.as_deref(), Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -307,10 +315,12 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); @@ -320,6 +330,7 @@ pub async fn run_login_with_device_code( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -352,10 +363,12 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; @@ -366,6 +379,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -409,12 +423,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_route_config = config.auth_route_config(); match CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -459,11 +475,13 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_route_config = config.auth_route_config(); match logout_with_revoke( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -536,6 +554,7 @@ mod tests { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9d278c2122..8fd1358efd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1738,9 +1738,13 @@ async fn load_exec_server_remote_auth_provider( let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth = - CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) - .await?; + let auth_route_config = config.auth_route_config(); + let auth = CodexAuth::from_agent_identity_jwt( + &agent_identity_jwt, + Some(&config.chatgpt_base_url), + auth_route_config.as_ref(), + ) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 1466f10394..7baee7d349 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -640,7 +640,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let env_display = format_env_display(env.as_ref(), env_vars); let cwd_display = cwd .as_ref() - .map(|path| path.display().to_string()) + .map(ToString::to_string) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "-".to_string()); let status = format_mcp_status(cfg); @@ -897,7 +897,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re println!(" args: {args_display}"); let cwd_display = cwd .as_ref() - .map(|path| path.display().to_string()) + .map(ToString::to_string) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "-".to_string()); println!(" cwd: {cwd_display}"); diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 1fc670c871..433714a00c 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -566,11 +566,13 @@ pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { return Some(CodexAuth::from_api_key(&api_key).api_auth_mode()); } + let auth_route_config = config.auth_route_config(); CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await .ok() diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 5ec1ab260f..e4266c6195 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -7,6 +7,7 @@ use codex_config::CloudConfigBundleLoader; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -58,6 +59,7 @@ pub async fn cloud_config_bundle_loader_for_storage( credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, chatgpt_base_url: String, + auth_route_config: Option, ) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( codex_home.clone(), @@ -66,6 +68,7 @@ pub async fn cloud_config_bundle_loader_for_storage( /*forced_chatgpt_workspace_id*/ None, Some(chatgpt_base_url.clone()), keyring_backend_kind, + auth_route_config, ) .await; cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 96fed2f589..dfd4fe50ce 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -53,6 +53,7 @@ async fn auth_manager_with_api_key() -> Arc { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -83,6 +84,7 @@ async fn auth_manager_with_plan_and_identity( /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -102,12 +104,13 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc { agent_private_key: key_material.private_key_pkcs8_base64, account_id: "account-12345".to_string(), chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Business, chatgpt_account_is_fedramp: false, task_id: Some("task-123".to_string()), }, "https://auth.openai.com/api/accounts", + /*auth_route_config*/ None, ) .await .expect("agent identity record should be complete"), @@ -686,6 +689,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -742,6 +746,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -806,6 +811,7 @@ async fn get_bundle_surfaces_auth_recovery_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -872,6 +878,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 65db955f15..2a836f8579 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -52,6 +52,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option ExecuteRequest { ExecuteRequest { diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index d42ce48a24..24eca22517 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -22,23 +22,24 @@ use self::conversions::output_item; use self::conversions::runtime_request; use self::types::CellCommand; pub(crate) use self::types::CellError; -pub(crate) use self::types::CellEvent; pub(crate) use self::types::CellEventFuture; pub(crate) use self::types::CellHandle; pub(crate) use self::types::CellHost; -pub(crate) use self::types::CellImageDetail; -pub(crate) use self::types::CellOutputItem; -pub(crate) use self::types::CellRequest; +pub(crate) use self::types::CellState; pub(crate) use self::types::CellToolCall; -pub(crate) use self::types::CellToolDefinition; -pub(crate) use self::types::CellToolKind; -pub(crate) use self::types::CellToolName; -pub(crate) use self::types::ObserveMode; +pub(crate) use self::types::CompletionCommit; +use self::types::CompletionDelivery; +use self::types::ObservationDelivery; use crate::runtime::PendingRuntimeMode; use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeControlCommand; use crate::runtime::RuntimeEvent; use crate::runtime::spawn_runtime; +use crate::session_runtime::CellEvent; +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem; +use crate::session_runtime::ToolName as CellToolName; pub(crate) struct CellActor; @@ -48,6 +49,7 @@ impl CellActor { stored_values: HashMap, host: Arc, initial_observe_mode: ObserveMode, + cell_state: Arc, ) -> Result< ( CellHandle, @@ -65,15 +67,14 @@ impl CellActor { event_tx, PendingRuntimeMode::PauseUntilResumed, )?; - let cancellation_token = CancellationToken::new(); - let handle = CellHandle::new(command_tx, cancellation_token.clone()); + let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = run_cell( host, CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token, + cell_state, }, event_rx, command_rx, @@ -92,7 +93,7 @@ struct CellContext { runtime_tx: std::sync::mpsc::Sender, runtime_control_tx: std::sync::mpsc::Sender, runtime_terminate_handle: v8::IsolateHandle, - cancellation_token: CancellationToken, + cell_state: Arc, } struct Observer { @@ -100,115 +101,130 @@ struct Observer { response_tx: oneshot::Sender>, } -struct Termination { - response_tx: Option>>, -} - async fn run_cell( host: Arc, context: CellContext, mut event_rx: mpsc::UnboundedReceiver, - mut command_rx: mpsc::UnboundedReceiver, + command_rx: mpsc::UnboundedReceiver, initial_observer: Observer, ) { let CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token, + cell_state, } = context; + let cancellation_token = cell_state.cancellation_token(); + let callback_cancellation_token = cancellation_token.child_token(); let mut content_items = Vec::new(); let mut pending_tool_call_ids = Vec::new(); - let mut completed_event = None; + let mut pending_frontier_ready = false; let mut observer = Some(initial_observer); - let mut termination: Option = None; + let mut termination = false; let mut runtime_closed = false; let mut runtime_paused = false; let mut yield_timer: Option>> = None; let mut notification_tasks = JoinSet::new(); let mut tool_tasks = JoinSet::new(); - + let mut command_rx = Some(command_rx); loop { let yield_deadline_elapsed = yield_timer .as_ref() .is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now()); tokio::select! { biased; - maybe_command = command_rx.recv() => { - let Some(command) = maybe_command else { - if completed_event.is_some() { - break; - } - termination = Some(Termination { response_tx: None }); - begin_termination( - &runtime_tx, - &runtime_control_tx, - &runtime_terminate_handle, - &cancellation_token, + _ = cancellation_token.cancelled(), if !termination => { + termination = true; + yield_timer = None; + drop(command_rx.take()); + begin_termination( + &runtime_tx, + &runtime_control_tx, + &runtime_terminate_handle, + &cancellation_token, + ); + if runtime_closed { + finish_callbacks( + &callback_cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), + CellEvent::Terminated { + content_items: std::mem::take(&mut content_items), + }, ); - if runtime_closed { - break; - } + break; + } + } + maybe_command = async { + match command_rx.as_mut() { + Some(command_rx) => command_rx.recv().await, + None => std::future::pending::>().await, + } + } => { + let Some(CellCommand::Observe { mode, response_tx }) = maybe_command else { + cancellation_token.cancel(); continue; }; - match command { - CellCommand::Observe { mode, response_tx } => { - if let Some(event) = completed_event.take() { - let _ = response_tx.send(Ok(event)); - break; - } - if observer.is_some() || termination.is_some() { - let _ = response_tx.send(Err(CellError::Busy)); - continue; - } - observer = Some(Observer { mode, response_tx }); - yield_timer = observer.as_ref().and_then(observer_timer); - resume_for_observation( - mode, - &mut runtime_paused, - &runtime_tx, - &runtime_control_tx, - ); - } - CellCommand::Terminate { response_tx } => { - if let Some(event) = completed_event.take() { - if let Some(response_tx) = response_tx { - let _ = response_tx.send(Ok(event)); - } - break; - } - if termination.is_some() { - if let Some(response_tx) = response_tx { - let _ = response_tx.send(Err(CellError::AlreadyTerminating)); - } - continue; - } - termination = Some(Termination { response_tx }); - yield_timer = None; - begin_termination( - &runtime_tx, - &runtime_control_tx, - &runtime_terminate_handle, - &cancellation_token, - ); - if runtime_closed { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::Cancel, - ).await; - send_termination_events( - observer.take(), - termination.take(), - CellEvent::Terminated { - content_items: std::mem::take(&mut content_items), - }, - ); - break; - } - } + if response_tx.is_closed() { + continue; } + let response_tx = match cell_state.route_observation(mode, response_tx) { + ObservationDelivery::Running(response_tx) => response_tx, + ObservationDelivery::Delivered => break, + ObservationDelivery::Buffered | ObservationDelivery::Closed => continue, + }; + if observer + .as_ref() + .is_some_and(|observer| observer.response_tx.is_closed()) + { + observer = None; + yield_timer = None; + } + if observer.is_some() || termination { + let _ = response_tx.send(Err(CellError::Busy)); + continue; + } + if matches!(mode, ObserveMode::PendingFrontier) && pending_frontier_ready { + pending_frontier_ready = false; + match send_cell_event( + response_tx, + CellEvent::Pending { + content_items: std::mem::take(&mut content_items), + pending_tool_call_ids: std::mem::take(&mut pending_tool_call_ids), + }, + ) { + Ok(()) => {} + Err(CellEvent::Pending { + content_items: undelivered_items, + pending_tool_call_ids: undelivered_tool_call_ids, + }) => { + content_items = undelivered_items; + pending_tool_call_ids = undelivered_tool_call_ids; + pending_frontier_ready = true; + } + Err(event) => { + panic!("pending delivery returned an unexpected event: {event:?}") + } + } + continue; + } + observer = Some(Observer { mode, response_tx }); + yield_timer = observer.as_ref().and_then(observer_timer); + if runtime_paused && matches!(mode, ObserveMode::YieldAfter(_)) { + pending_frontier_ready = false; + pending_tool_call_ids.clear(); + } + resume_for_observation( + mode, + &mut runtime_paused, + &runtime_tx, + &runtime_control_tx, + ); } _ = async { if let Some(yield_timer) = yield_timer.as_mut() { @@ -218,11 +234,14 @@ async fn run_cell( } } => { yield_timer = None; - send_observer_event( - observer.take(), - CellEvent::Yielded { - content_items: std::mem::take(&mut content_items), - }, + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, ); } maybe_event = async { @@ -234,38 +253,58 @@ async fn run_cell( }, if !yield_deadline_elapsed => { let Some(event) = maybe_event else { runtime_closed = true; - if termination.is_some() { + if termination || cancellation_token.is_cancelled() { finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, ).await; - send_termination_events( - observer.take(), - termination.take(), + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), CellEvent::Terminated { content_items: std::mem::take(&mut content_items), }, ); break; } - if completed_event.is_none() { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::DrainNotifications, - ).await; - let event = CellEvent::Completed { - content_items: std::mem::take(&mut content_items), - error_text: Some("exec runtime ended unexpectedly".to_string()), - }; - if send_or_buffer_completion( + finish_callbacks( + &callback_cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::DrainNotifications, + ) + .await; + let event = CellEvent::Completed { + content_items: std::mem::take(&mut content_items), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + let rejected_event = match host + .commit_completion( + HashMap::new(), event, - &mut observer, - &mut completed_event, - ) { + /*pending_initial_yield_items*/ None, + Arc::clone(&cell_state), + ) + .await + { + CompletionCommit::Committed => None, + CompletionCommit::Rejected(event) => Some(event), + }; + match cell_state.deliver_completion( + observer.take().map(|observer| observer.response_tx), + ) { + CompletionDelivery::Delivered => break, + CompletionDelivery::Buffered => {} + CompletionDelivery::Rejected(response_tx) => { + finish_termination( + &cell_state, + response_tx, + CellEvent::Terminated { + content_items: rejected_completion_content(rejected_event), + }, + ); break; } } @@ -282,7 +321,8 @@ async fn run_cell( Some(ObserveMode::PendingFrontier) ) { yield_timer = None; - send_observer_event( + pending_frontier_ready = false; + match send_observer_event( observer.take(), CellEvent::Pending { content_items: std::mem::take(&mut content_items), @@ -290,7 +330,20 @@ async fn run_cell( &mut pending_tool_call_ids, ), }, - ); + ) { + Ok(()) => {} + Err(CellEvent::Pending { + content_items: undelivered_items, + pending_tool_call_ids: undelivered_tool_call_ids, + }) => { + content_items = undelivered_items; + pending_tool_call_ids = undelivered_tool_call_ids; + pending_frontier_ready = true; + } + Err(event) => { + panic!("pending delivery returned an unexpected event: {event:?}") + } + } } else { pending_tool_call_ids.clear(); let _ = runtime_control_tx.send(RuntimeControlCommand::Continue); @@ -299,16 +352,20 @@ async fn run_cell( } RuntimeEvent::ContentItem(item) => content_items.push(output_item(item)), RuntimeEvent::YieldRequested => { - if matches!( + let yield_observer = matches!( observer.as_ref().map(|observer| observer.mode), Some(ObserveMode::YieldAfter(_)) - ) { + ); + if yield_observer { yield_timer = None; - send_observer_event( - observer.take(), - CellEvent::Yielded { - content_items: std::mem::take(&mut content_items), - }, + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, ); } } @@ -318,7 +375,7 @@ async fn run_cell( Arc::clone(&host), call_id, text, - cancellation_token.child_token(), + callback_cancellation_token.child_token(), ); } RuntimeEvent::ToolCall { id, name, kind, input } => { @@ -336,22 +393,22 @@ async fn run_cell( input, }, runtime_tx.clone(), - cancellation_token.child_token(), + callback_cancellation_token.child_token(), ); } RuntimeEvent::Result { stored_value_writes, error_text } => { runtime_closed = true; yield_timer = None; - if termination.is_some() { + if termination || cancellation_token.is_cancelled() { finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, ).await; - send_termination_events( - observer.take(), - termination.take(), + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), CellEvent::Terminated { content_items: std::mem::take(&mut content_items), }, @@ -359,22 +416,43 @@ async fn run_cell( break; } finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::DrainNotifications, - ).await; - host.commit_stored_values(stored_value_writes).await; + ) + .await; let event = CellEvent::Completed { content_items: std::mem::take(&mut content_items), error_text, }; - if send_or_buffer_completion( - event, - &mut observer, - &mut completed_event, + let rejected_event = match host + .commit_completion( + stored_value_writes, + event, + /*pending_initial_yield_items*/ None, + Arc::clone(&cell_state), + ) + .await + { + CompletionCommit::Committed => None, + CompletionCommit::Rejected(event) => Some(event), + }; + match cell_state.deliver_completion( + observer.take().map(|observer| observer.response_tx), ) { - break; + CompletionDelivery::Delivered => break, + CompletionDelivery::Buffered => {} + CompletionDelivery::Rejected(response_tx) => { + finish_termination( + &cell_state, + response_tx, + CellEvent::Terminated { + content_items: rejected_completion_content(rejected_event), + }, + ); + break; + } } } } @@ -387,6 +465,9 @@ async fn run_cell( } } } + // Reject requests that arrive while asynchronous terminal cleanup runs. + cell_state.tombstone(); + drop(command_rx.take()); begin_termination( &runtime_tx, &runtime_control_tx, @@ -394,7 +475,7 @@ async fn run_cell( &cancellation_token, ); finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, @@ -403,34 +484,54 @@ async fn run_cell( host.closed().await; } -fn send_or_buffer_completion( +fn send_observer_event(observer: Option, event: CellEvent) -> Result<(), CellEvent> { + let Some(observer) = observer else { + return Err(event); + }; + send_cell_event(observer.response_tx, event) +} + +fn send_cell_event( + response_tx: oneshot::Sender>, event: CellEvent, - observer: &mut Option, - completed_event: &mut Option, -) -> bool { - if observer.is_some() { - send_observer_event(observer.take(), event); - true - } else { - *completed_event = Some(event); - false +) -> Result<(), CellEvent> { + match response_tx.send(Ok(event)) { + Ok(()) => Ok(()), + Err(Ok(event)) => Err(event), + Err(Err(error)) => panic!("cell event delivery returned an actor error: {error:?}"), } } -fn send_observer_event(observer: Option, event: CellEvent) { - if let Some(observer) = observer { - let _ = observer.response_tx.send(Ok(event)); +fn restore_undelivered_yield(delivery: Result<(), CellEvent>, content_items: &mut Vec) { + match delivery { + Ok(()) => {} + Err(CellEvent::Yielded { + content_items: mut undelivered_items, + }) => { + undelivered_items.append(content_items); + *content_items = undelivered_items; + } + Err(event) => panic!("yield delivery returned an unexpected event: {event:?}"), } } -fn send_termination_events( - observer: Option, - termination: Option, +fn rejected_completion_content(event: Option) -> Vec { + match event { + Some(CellEvent::Completed { content_items, .. }) => content_items, + None => Vec::new(), + Some(event) => panic!("completion commit rejected an unexpected event: {event:?}"), + } +} + +fn finish_termination( + cell_state: &CellState, + observer_tx: Option>>, event: CellEvent, ) { - send_observer_event(observer, event.clone()); - if let Some(response_tx) = termination.and_then(|termination| termination.response_tx) { - let _ = response_tx.send(Ok(event)); + if let Some(event) = cell_state.finish_termination(event) + && let Some(observer_tx) = observer_tx + { + let _ = observer_tx.send(Ok(event)); } } diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index 218f1fcde4..a4d93fc3e6 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -1,5 +1,8 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::mpsc as std_mpsc; use std::time::Duration; use codex_code_mode_protocol::ExecuteRequest; @@ -11,9 +14,15 @@ use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use super::*; +use crate::session_runtime::OutputItem; struct TestHost; +#[derive(Default)] +struct RecordingHost { + notified: AtomicBool, +} + impl CellHost for TestHost { async fn invoke_tool( &self, @@ -32,7 +41,47 @@ impl CellHost for TestHost { Ok(()) } - async fn commit_stored_values(&self, _stored_value_writes: HashMap) {} + async fn commit_completion( + &self, + _stored_value_writes: HashMap, + event: CellEvent, + pending_initial_yield_items: Option>, + cell_state: Arc, + ) -> CompletionCommit { + cell_state.commit_completion(event, pending_initial_yield_items, || {}) + } + + async fn closed(&self) {} +} + +impl CellHost for RecordingHost { + async fn invoke_tool( + &self, + _invocation: CellToolCall, + _cancellation_token: CancellationToken, + ) -> Result { + Err("unexpected tool call".to_string()) + } + + async fn notify( + &self, + _call_id: String, + _text: String, + _cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.notified.store(true, Ordering::Release); + Ok(()) + } + + async fn commit_completion( + &self, + _stored_value_writes: HashMap, + event: CellEvent, + pending_initial_yield_items: Option>, + cell_state: Arc, + ) -> CompletionCommit { + cell_state.commit_completion(event, pending_initial_yield_items, || {}) + } async fn closed(&self) {} } @@ -42,15 +91,23 @@ struct CellActorHarness { handle: CellHandle, initial_event_rx: oneshot::Receiver>, task: tokio::task::JoinHandle<()>, + runtime_control_rx: std_mpsc::Receiver, _runtime_event_rx: mpsc::UnboundedReceiver, } fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarness { + spawn_cell_actor_harness_with_host(initial_observe_mode, Arc::new(TestHost)) +} + +fn spawn_cell_actor_harness_with_host( + initial_observe_mode: ObserveMode, + host: Arc, +) -> CellActorHarness { let (event_tx, event_rx) = mpsc::unbounded_channel(); let (command_tx, command_rx) = mpsc::unbounded_channel(); let (initial_event_tx, initial_event_rx) = oneshot::channel(); let (runtime_event_tx, runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + let (runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime( HashMap::new(), ExecuteRequest { tool_call_id: "call-1".to_string(), @@ -63,14 +120,16 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne PendingRuntimeMode::PauseUntilResumed, ) .unwrap(); - let handle = CellHandle::new(command_tx, CancellationToken::new()); + let (runtime_control_tx, runtime_control_rx) = std_mpsc::channel(); + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = tokio::spawn(run_cell( - Arc::new(TestHost), + host, CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token: CancellationToken::new(), + cell_state, }, event_rx, command_rx, @@ -85,10 +144,21 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne handle, initial_event_rx, task, + runtime_control_rx, _runtime_event_rx: runtime_event_rx, } } +async fn wait_for_notification(host: &RecordingHost) { + tokio::time::timeout(Duration::from_secs(1), async { + while !host.notified.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("notification barrier timed out"); +} + #[tokio::test] async fn yield_timer_preempts_buffered_runtime_output() { let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::ZERO)); @@ -114,7 +184,7 @@ async fn yield_timer_preempts_buffered_runtime_output() { assert_eq!( termination.await, Ok(CellEvent::Terminated { - content_items: vec![CellOutputItem::Text { + content_items: vec![OutputItem::Text { text: "queued output".to_string(), }], }) @@ -141,3 +211,420 @@ async fn queued_termination_preempts_unobserved_runtime_completion() { assert_eq!(harness.initial_event_rx.await.unwrap(), terminated); harness.task.await.unwrap(); } + +#[tokio::test] +async fn observation_dropped_before_dequeue_does_not_consume_output() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + drop( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::from_secs(60))), + ); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "survives pre-dequeue cancellation".to_string(), + }, + )) + .unwrap(); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-command".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "survives pre-dequeue cancellation".to_string(), + }], + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn dropped_yield_observer_preserves_output_for_the_next_observation() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + let dropped_observation = harness + .handle + .observe(ObserveMode::YieldAfter(Duration::from_secs(60))); + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Err(CellError::Busy) + ); + drop(dropped_observation); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "survives active cancellation".to_string(), + }, + )) + .unwrap(); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-observer".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "survives active cancellation".to_string(), + }], + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observation() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + let dropped_observation = harness.handle.observe(ObserveMode::PendingFrontier); + assert_eq!( + harness.handle.observe(ObserveMode::PendingFrontier).await, + Err(CellError::Busy) + ); + drop(dropped_observation); + harness + .event_tx + .send(RuntimeEvent::ToolCall { + id: "tool-1".to_string(), + name: codex_protocol::ToolName { + name: "echo".to_string(), + namespace: None, + }, + kind: codex_code_mode_protocol::CodeModeToolKind::Function, + input: Some(serde_json::json!({})), + }) + .unwrap(); + harness.event_tx.send(RuntimeEvent::Pending).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-pending".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness.handle.observe(ObserveMode::PendingFrontier).await, + Ok(CellEvent::Pending { + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + }) + ); + assert!(matches!( + harness.runtime_control_rx.try_recv(), + Err(std_mpsc::TryRecvError::Empty) + )); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn only_the_first_termination_claims_a_buffered_completion() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + completion.clone(), + /*pending_initial_yield_items*/ None, + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let first_termination = cell_state.request_termination(); + assert_eq!( + cell_state.request_termination().await, + Err(CellError::AlreadyTerminating) + ); + assert_eq!(first_termination.await, Ok(completion.clone())); + assert_eq!( + cell_state.finish_termination(CellEvent::Terminated { + content_items: Vec::new(), + }), + Some(completion) + ); +} + +#[tokio::test] +async fn termination_claim_prevents_stored_value_commit() { + let cell_state = CellState::new(CancellationToken::new()); + let termination = cell_state.request_termination(); + let mut commit_ran = false; + let completion = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + + assert_eq!( + cell_state.commit_completion( + completion.clone(), + /*pending_initial_yield_items*/ None, + || commit_ran = true + ), + CompletionCommit::Rejected(completion) + ); + assert!(!commit_ran); + + let terminated = CellEvent::Terminated { + content_items: Vec::new(), + }; + assert_eq!( + cell_state.finish_termination(terminated.clone()), + Some(terminated.clone()) + ); + assert_eq!(termination.await, Ok(terminated)); +} + +#[test] +fn failed_completion_delivery_rebuffers_the_event() { + let cell_state = CellState::new(CancellationToken::new()); + let event = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + event.clone(), + /*pending_initial_yield_items*/ None, + || {} + ), + CompletionCommit::Committed + ); + let (response_tx, response_rx) = oneshot::channel(); + drop(response_rx); + assert!(matches!( + cell_state.deliver_completion(Some(response_tx)), + CompletionDelivery::Buffered + )); + assert!(cell_state.accepting_observations()); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(event))); +} + +#[test] +fn buffered_initial_yield_precedes_buffered_completion_for_yield_observer() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + completion.clone(), + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Buffered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "before".to_string(), + }], + })) + ); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); +} + +#[test] +fn pending_observer_merges_initial_yield_and_completion_output() { + let cell_state = CellState::new(CancellationToken::new()); + assert_eq!( + cell_state.commit_completion( + CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }, + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), + ObservationDelivery::Delivered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Completed { + content_items: vec![ + OutputItem::Text { + text: "before".to_string(), + }, + OutputItem::Text { + text: "after".to_string(), + }, + ], + error_text: None, + })) + ); +} + +#[test] +fn dropped_pending_observation_preserves_the_initial_yield_boundary() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + completion.clone(), + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, response_rx) = oneshot::channel(); + drop(response_rx); + assert!(matches!( + cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), + ObservationDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Buffered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "before".to_string(), + }], + })) + ); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); +} diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index aef0b5211f..672820b93e 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -2,42 +2,22 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::time::Duration; +use std::sync::Mutex; use serde_json::Value as JsonValue; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; +use crate::session_runtime::CellEvent; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem; +use crate::session_runtime::ToolKind; +use crate::session_runtime::ToolName; + pub(crate) type CellEventFuture = Pin> + Send + 'static>>; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ObserveMode { - YieldAfter(Duration), - PendingFrontier, -} - -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum CellEvent { - Yielded { - content_items: Vec, - }, - Pending { - content_items: Vec, - pending_tool_call_ids: Vec, - }, - Completed { - content_items: Vec, - error_text: Option, - }, - Terminated { - content_items: Vec, - }, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CellError { Busy, @@ -45,61 +25,18 @@ pub(crate) enum CellError { Closed, } -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum CellOutputItem { - Text { - text: String, - }, - Image { - image_url: String, - detail: Option, - }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum CellImageDetail { - Auto, - Low, - High, - Original, -} - -pub(crate) struct CellRequest { - pub(crate) tool_call_id: String, - pub(crate) enabled_tools: Vec, - pub(crate) source: String, -} - -pub(crate) struct CellToolDefinition { - pub(crate) name: String, - pub(crate) tool_name: CellToolName, - pub(crate) description: String, - pub(crate) kind: CellToolKind, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CellToolName { - pub(crate) name: String, - pub(crate) namespace: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum CellToolKind { - Function, - Freeform, -} - pub(crate) struct CellToolCall { pub(crate) id: String, - pub(crate) name: CellToolName, - pub(crate) kind: CellToolKind, + pub(crate) name: ToolName, + pub(crate) kind: ToolKind, pub(crate) input: Option, } -/// Connects a cell actor to session-owned callbacks and lifecycle state. +/// Connects a cell actor to session-owned callbacks and stored values. /// -/// Implementations must honor callback cancellation and must not return from -/// `closed` until the session can no longer route requests to the cell. +/// Implementations should forward callback cancellation to downstream work. +/// Implementations must not return from `closed` until the session can no longer +/// route requests to the cell. pub(crate) trait CellHost: Send + Sync + 'static { fn invoke_tool( &self, @@ -114,10 +51,13 @@ pub(crate) trait CellHost: Send + Sync + 'static { cancellation_token: CancellationToken, ) -> impl Future> + Send; - fn commit_stored_values( + fn commit_completion( &self, stored_value_writes: HashMap, - ) -> impl Future + Send; + event: CellEvent, + pending_initial_yield_items: Option>, + cell_state: Arc, + ) -> impl Future + Send; fn closed(&self) -> impl Future + Send; } @@ -125,23 +65,21 @@ pub(crate) trait CellHost: Send + Sync + 'static { #[derive(Clone)] pub(crate) struct CellHandle { command_tx: mpsc::UnboundedSender, - cancellation_token: CancellationToken, - termination_requested: Arc, + state: Arc, } impl CellHandle { pub(super) fn new( command_tx: mpsc::UnboundedSender, - cancellation_token: CancellationToken, + state: Arc, ) -> Self { - Self { - command_tx, - cancellation_token, - termination_requested: Arc::new(AtomicBool::new(false)), - } + Self { command_tx, state } } pub(crate) fn observe(&self, mode: ObserveMode) -> CellEventFuture { + if !self.state.accepting_observations() { + return closed_event(); + } let (response_tx, response_rx) = oneshot::channel(); if self .command_tx @@ -154,33 +92,335 @@ impl CellHandle { } pub(crate) fn terminate(&self) -> CellEventFuture { - if self - .termination_requested - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - return Box::pin(async { Err(CellError::AlreadyTerminating) }); + self.state.request_termination() + } +} + +/// The single linearization point for a cell's terminal outcome. +/// +/// The cancellation token is a child of the owning session token. Callback +/// tokens are children of this token, so cancellation flows strictly from the +/// session to the cell and then to its callbacks. +/// +/// The mutex is held only for synchronous phase transitions and terminal +/// delivery. Runtime execution, observation waits, and callbacks never run +/// while it is held. +pub(crate) struct CellState { + phase: Mutex, + cancellation_token: CancellationToken, +} + +enum CellPhase { + Running, + Terminating { + response_tx: oneshot::Sender>, + }, + Completed { + // Set only when `yield_control()` races the create-to-first-observe handoff. + pending_initial_yield_items: Option>, + event: CellEvent, + }, + CompletionClaimed(CellEvent), + Tombstone, +} + +pub(crate) enum CompletionDelivery { + Delivered, + Buffered, + Rejected(Option>>), +} + +/// Result of atomically publishing a completed cell and its session side effects. +#[derive(Debug, PartialEq)] +pub(crate) enum CompletionCommit { + Committed, + Rejected(CellEvent), +} + +pub(crate) enum ObservationDelivery { + Running(oneshot::Sender>), + Delivered, + Buffered, + Closed, +} + +impl CellState { + pub(crate) fn new(cancellation_token: CancellationToken) -> Self { + Self { + phase: Mutex::new(CellPhase::Running), + cancellation_token, } - let (response_tx, response_rx) = oneshot::channel(); - if self - .command_tx - .send(CellCommand::Terminate { - response_tx: Some(response_tx), - }) - .is_err() - { - self.termination_requested.store(false, Ordering::Relaxed); - return closed_event(); - } - response_event(response_rx) } - pub(crate) fn shutdown(&self) { - self.termination_requested.store(true, Ordering::Relaxed); + pub(crate) fn accepting_observations(&self) -> bool { + let accepting_phase = matches!( + *self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + CellPhase::Running | CellPhase::Completed { .. } + ); + accepting_phase && !self.cancellation_token.is_cancelled() + } + + pub(crate) fn request_termination(&self) -> CellEventFuture { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => { + let (response_tx, response_rx) = oneshot::channel(); + *phase = CellPhase::Terminating { response_tx }; + self.cancellation_token.cancel(); + response_event(response_rx) + } + CellPhase::Terminating { response_tx } => { + *phase = CellPhase::Terminating { response_tx }; + Box::pin(async { Err(CellError::AlreadyTerminating) }) + } + CellPhase::Completed { + pending_initial_yield_items, + event, + } => { + let event = prepend_initial_yield(event, pending_initial_yield_items); + *phase = CellPhase::CompletionClaimed(event.clone()); + self.cancellation_token.cancel(); + ready_event(event) + } + CellPhase::CompletionClaimed(event) => { + *phase = CellPhase::CompletionClaimed(event); + Box::pin(async { Err(CellError::AlreadyTerminating) }) + } + CellPhase::Tombstone => closed_event(), + } + } + + pub(crate) fn commit_completion( + &self, + event: CellEvent, + pending_initial_yield_items: Option>, + commit: impl FnOnce(), + ) -> CompletionCommit { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(*phase, CellPhase::Running) || self.cancellation_token.is_cancelled() { + return CompletionCommit::Rejected(event); + } + commit(); + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; + CompletionCommit::Committed + } + + pub(crate) fn deliver_completion( + &self, + response_tx: Option>>, + ) -> CompletionDelivery { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (pending_initial_yield_items, event) = + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Completed { + pending_initial_yield_items, + event, + } => (pending_initial_yield_items, event), + previous => { + *phase = previous; + return CompletionDelivery::Rejected(response_tx); + } + }; + let Some(response_tx) = response_tx else { + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; + return CompletionDelivery::Buffered; + }; + match response_tx.send(Ok(event)) { + Ok(()) => { + self.cancellation_token.cancel(); + CompletionDelivery::Delivered + } + Err(Ok(event)) => { + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; + CompletionDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } + } + } + + pub(crate) fn route_observation( + &self, + mode: ObserveMode, + response_tx: oneshot::Sender>, + ) -> ObservationDelivery { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => { + *phase = CellPhase::Running; + ObservationDelivery::Running(response_tx) + } + CellPhase::Completed { + pending_initial_yield_items: Some(content_items), + event, + } if matches!(mode, ObserveMode::YieldAfter(_)) => { + match response_tx.send(Ok(CellEvent::Yielded { content_items })) { + Ok(()) => { + *phase = CellPhase::Completed { + pending_initial_yield_items: None, + event, + }; + ObservationDelivery::Buffered + } + Err(Ok(CellEvent::Yielded { content_items })) => { + *phase = CellPhase::Completed { + pending_initial_yield_items: Some(content_items), + event, + }; + ObservationDelivery::Buffered + } + Err(Ok(event)) => { + panic!("initial yield delivery returned an unexpected event: {event:?}") + } + Err(Err(error)) => { + panic!("initial yield delivery returned an actor error: {error:?}") + } + } + } + CellPhase::Completed { + pending_initial_yield_items, + event, + } => { + let delivered_event = + prepend_initial_yield(event.clone(), pending_initial_yield_items.clone()); + match response_tx.send(Ok(delivered_event)) { + Ok(()) => { + self.cancellation_token.cancel(); + ObservationDelivery::Delivered + } + Err(Ok(_)) => { + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; + ObservationDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } + } + } + CellPhase::Terminating { + response_tx: termination_tx, + } => { + *phase = CellPhase::Terminating { + response_tx: termination_tx, + }; + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + CellPhase::CompletionClaimed(event) => { + *phase = CellPhase::CompletionClaimed(event); + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + CellPhase::Tombstone => { + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + } + } + + pub(crate) fn finish_termination(&self, event: CellEvent) -> Option { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let observer_event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => Some(event), + CellPhase::Terminating { response_tx } => { + let _ = response_tx.send(Ok(event.clone())); + Some(event) + } + CellPhase::Completed { + pending_initial_yield_items, + event, + } => Some(prepend_initial_yield(event, pending_initial_yield_items)), + CellPhase::CompletionClaimed(completed_event) => Some(completed_event), + CellPhase::Tombstone => None, + }; self.cancellation_token.cancel(); - let _ = self - .command_tx - .send(CellCommand::Terminate { response_tx: None }); + observer_event + } + + pub(crate) fn tombstone(&self) { + *self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = CellPhase::Tombstone; + self.cancellation_token.cancel(); + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } +} + +fn prepend_initial_yield( + event: CellEvent, + pending_initial_yield_items: Option>, +) -> CellEvent { + let Some(mut pending_initial_yield_items) = pending_initial_yield_items else { + return event; + }; + match event { + CellEvent::Yielded { mut content_items } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Yielded { + content_items: pending_initial_yield_items, + } + } + CellEvent::Pending { + mut content_items, + pending_tool_call_ids, + } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Pending { + content_items: pending_initial_yield_items, + pending_tool_call_ids, + } + } + CellEvent::Completed { + mut content_items, + error_text, + } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Completed { + content_items: pending_initial_yield_items, + error_text, + } + } + CellEvent::Terminated { mut content_items } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Terminated { + content_items: pending_initial_yield_items, + } + } } } @@ -189,15 +429,16 @@ pub(super) enum CellCommand { mode: ObserveMode, response_tx: oneshot::Sender>, }, - Terminate { - response_tx: Option>>, - }, } fn response_event(response_rx: oneshot::Receiver>) -> CellEventFuture { Box::pin(async move { response_rx.await.unwrap_or(Err(CellError::Closed)) }) } +fn ready_event(event: CellEvent) -> CellEventFuture { + Box::pin(async move { Ok(event) }) +} + fn closed_event() -> CellEventFuture { Box::pin(async { Err(CellError::Closed) }) } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 76f8aa9b97..cbe8bfe6df 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -1,6 +1,7 @@ mod cell_actor; mod runtime; mod service; +mod session_runtime; pub use codex_code_mode_protocol::*; pub use service::CodeModeService; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index bfc54f71be..42af5c0b95 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -420,7 +420,7 @@ await new Promise(() => {}); .send(RuntimeCommand::TimeoutFired { id: 1 }) .unwrap(); assert!( - tokio::time::timeout(Duration::from_millis(100), event_rx.recv()) + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) .await .is_err() ); diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 4c8fa517ae..bb7aa842e1 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1,8 +1,4 @@ -use std::collections::HashMap; use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; use std::time::Duration; use codex_code_mode_protocol::CellId; @@ -27,24 +23,11 @@ use codex_code_mode_protocol::WaitRequest; use codex_code_mode_protocol::WaitToPendingOutcome; use codex_code_mode_protocol::WaitToPendingRequest; use serde_json::Value as JsonValue; -use tokio::sync::Mutex; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; -use crate::cell_actor::CellActor; -use crate::cell_actor::CellError; -use crate::cell_actor::CellEvent; -use crate::cell_actor::CellEventFuture; -use crate::cell_actor::CellHandle; -use crate::cell_actor::CellHost; -use crate::cell_actor::CellImageDetail; -use crate::cell_actor::CellOutputItem; -use crate::cell_actor::CellRequest; -use crate::cell_actor::CellToolCall; -use crate::cell_actor::CellToolDefinition; -use crate::cell_actor::CellToolKind; -use crate::cell_actor::CellToolName; -use crate::cell_actor::ObserveMode; +use crate::session_runtime as runtime; +use crate::session_runtime::SessionRuntime; pub struct NoopCodeModeSessionDelegate; @@ -89,16 +72,8 @@ impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { } } -struct Inner { - stored_values: Mutex>, - cells: Mutex>, - delegate: Arc, - shutting_down: AtomicBool, - next_cell_id: AtomicU64, -} - pub struct CodeModeService { - inner: Arc, + runtime: SessionRuntime, } impl CodeModeService { @@ -108,45 +83,31 @@ impl CodeModeService { pub fn with_delegate(delegate: Arc) -> Self { Self { - inner: Arc::new(Inner { - stored_values: Mutex::new(HashMap::new()), - cells: Mutex::new(HashMap::new()), - delegate, - shutting_down: AtomicBool::new(false), - next_cell_id: AtomicU64::new(1), - }), + runtime: SessionRuntime::new(Arc::new(ProtocolDelegate { delegate })), } } - fn allocate_cell_id(&self) -> CellId { - CellId::new( - self.inner - .next_cell_id - .fetch_add(1, Ordering::Relaxed) - .to_string(), - ) - } - pub async fn execute(&self, request: ExecuteRequest) -> Result { let yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); - let cell_id = self.allocate_cell_id(); - let initial_event = self - .start_cell( - cell_id.clone(), - request, - ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), ) - .await?; + .await + .map_err(|error| error.to_string())?; + let cell_id = protocol_cell_id(&started.cell_id); let response_cell_id = cell_id.clone(); let (response_tx, response_rx) = oneshot::channel(); tokio::spawn(async move { - let response = initial_event + let response = started + .initial_event() .await - .map_err(|error| cell_error_text(&response_cell_id, error)) + .map_err(|error| error.to_string()) .and_then(|event| runtime_response(&response_cell_id, event)); let _ = response_tx.send(response); }); - Ok(StartedCell::from_result_receiver(cell_id, response_rx)) } @@ -154,45 +115,22 @@ impl CodeModeService { &self, request: ExecuteRequest, ) -> Result { - let cell_id = self.allocate_cell_id(); - let event = self - .start_cell(cell_id.clone(), request, ObserveMode::PendingFrontier) - .await? + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::PendingFrontier, + ) .await - .map_err(|error| cell_error_text(&cell_id, error))?; + .map_err(|error| error.to_string())?; + let cell_id = protocol_cell_id(&started.cell_id); + let event = started + .initial_event() + .await + .map_err(|error| error.to_string())?; pending_outcome(&cell_id, event) } - async fn start_cell( - &self, - cell_id: CellId, - request: ExecuteRequest, - initial_observe_mode: ObserveMode, - ) -> Result { - let stored_values = self.inner.stored_values.lock().await.clone(); - let host = Arc::new(ServiceCellHost { - cell_id: cell_id.clone(), - inner: Arc::clone(&self.inner), - }); - let mut cells = self.inner.cells.lock().await; - if self.inner.shutting_down.load(Ordering::Acquire) { - return Err("code mode session is shutting down".to_string()); - } - if cells.contains_key(&cell_id) { - return Err(format!("exec cell {cell_id} already exists")); - } - let (handle, initial_event, task) = CellActor::prepare( - cell_request(request), - stored_values, - host, - initial_observe_mode, - )?; - cells.insert(cell_id, handle); - drop(cells); - tokio::spawn(task); - Ok(initial_event) - } - pub async fn wait(&self, request: WaitRequest) -> Result { self.begin_wait(request).await.await } @@ -205,24 +143,39 @@ impl CodeModeService { cell_id, yield_time_ms, } = request; - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return missing_wait(cell_id); - }; - wait_for_event( - cell_id, - handle.observe(ObserveMode::YieldAfter(Duration::from_millis( - yield_time_ms, - ))), - ) + let runtime_cell_id = runtime_cell_id(&cell_id); + match self + .runtime + .begin_observe( + &runtime_cell_id, + runtime::ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + ) + .await + { + Ok(pending_event) => Box::pin(async move { + match pending_event.event().await { + Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) + } + Err(error) => Err(error.to_string()), + } + }), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + missing_wait(cell_id) + } + Err(error) => Box::pin(async move { Err(error.to_string()) }), + } } pub async fn terminate(&self, cell_id: CellId) -> Result { - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); - }; - wait_for_event(cell_id, handle.terminate()).await + match self.runtime.terminate(&runtime_cell_id(&cell_id)).await { + Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) + } + Err(error) => Err(error.to_string()), + } } pub async fn wait_to_pending( @@ -230,40 +183,29 @@ impl CodeModeService { request: WaitToPendingRequest, ) -> Result { let cell_id = request.cell_id; - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))); - }; - match handle.observe(ObserveMode::PendingFrontier).await { + match self + .runtime + .observe( + &runtime_cell_id(&cell_id), + runtime::ObserveMode::PendingFrontier, + ) + .await + { Ok(event) => Ok(WaitToPendingOutcome::LiveCell(pending_outcome( &cell_id, event, )?)), - Err(CellError::Closed) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))), - Err(error) => Err(cell_error_text(&cell_id, error)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => Ok( + WaitToPendingOutcome::MissingCell(missing_cell_response(cell_id)), + ), + Err(error) => Err(error.to_string()), } } pub async fn shutdown(&self) -> Result<(), String> { - self.inner.shutting_down.store(true, Ordering::Release); - let handles = self - .inner - .cells - .lock() + self.runtime + .shutdown() .await - .values() - .cloned() - .collect::>(); - for handle in handles { - handle.shutdown(); - } - while !self.inner.cells.lock().await.is_empty() { - tokio::task::yield_now().await; - } - Ok(()) + .map_err(|error| error.to_string()) } } @@ -273,20 +215,9 @@ impl Default for CodeModeService { } } -impl Drop for CodeModeService { - fn drop(&mut self) { - self.inner.shutting_down.store(true, Ordering::Release); - if let Ok(cells) = self.inner.cells.try_lock() { - for handle in cells.values() { - handle.shutdown(); - } - } - } -} - impl CodeModeSession for CodeModeService { fn is_alive(&self) -> bool { - !self.inner.shutting_down.load(Ordering::Acquire) + self.runtime.is_alive() } fn execute<'a>( @@ -309,30 +240,28 @@ impl CodeModeSession for CodeModeService { } } -struct ServiceCellHost { - cell_id: CellId, - inner: Arc, +struct ProtocolDelegate { + delegate: Arc, } -impl CellHost for ServiceCellHost { +impl runtime::SessionRuntimeDelegate for ProtocolDelegate { async fn invoke_tool( &self, - invocation: CellToolCall, + invocation: runtime::NestedToolCall, cancellation_token: CancellationToken, ) -> Result { - self.inner - .delegate + self.delegate .invoke_tool( CodeModeNestedToolCall { - cell_id: self.cell_id.clone(), - runtime_tool_call_id: invocation.id, + cell_id: protocol_cell_id(&invocation.cell_id), + runtime_tool_call_id: invocation.runtime_tool_call_id, tool_name: codex_protocol::ToolName { - name: invocation.name.name, - namespace: invocation.name.namespace, + name: invocation.tool_name.name, + namespace: invocation.tool_name.namespace, }, - tool_kind: match invocation.kind { - CellToolKind::Function => CodeModeToolKind::Function, - CellToolKind::Freeform => CodeModeToolKind::Freeform, + tool_kind: match invocation.tool_kind { + runtime::ToolKind::Function => CodeModeToolKind::Function, + runtime::ToolKind::Freeform => CodeModeToolKind::Freeform, }, input: invocation.input, }, @@ -344,45 +273,41 @@ impl CellHost for ServiceCellHost { async fn notify( &self, call_id: String, + cell_id: runtime::CellId, text: String, cancellation_token: CancellationToken, ) -> Result<(), String> { - self.inner - .delegate - .notify(call_id, self.cell_id.clone(), text, cancellation_token) + self.delegate + .notify( + call_id, + protocol_cell_id(&cell_id), + text, + cancellation_token, + ) .await } - async fn commit_stored_values(&self, stored_value_writes: HashMap) { - self.inner - .stored_values - .lock() - .await - .extend(stored_value_writes); - } - - async fn closed(&self) { - self.inner.cells.lock().await.remove(&self.cell_id); - self.inner.delegate.cell_closed(&self.cell_id); + fn cell_closed(&self, cell_id: &runtime::CellId) { + self.delegate.cell_closed(&protocol_cell_id(cell_id)); } } -fn cell_request(request: ExecuteRequest) -> CellRequest { - CellRequest { +fn runtime_request(request: ExecuteRequest) -> runtime::CreateCellRequest { + runtime::CreateCellRequest { tool_call_id: request.tool_call_id, enabled_tools: request .enabled_tools .into_iter() - .map(|definition| CellToolDefinition { + .map(|definition| runtime::ToolDefinition { name: definition.name, - tool_name: CellToolName { + tool_name: runtime::ToolName { name: definition.tool_name.name, namespace: definition.tool_name.namespace, }, description: definition.description, kind: match definition.kind { - CodeModeToolKind::Function => CellToolKind::Function, - CodeModeToolKind::Freeform => CellToolKind::Freeform, + CodeModeToolKind::Function => runtime::ToolKind::Function, + CodeModeToolKind::Freeform => runtime::ToolKind::Freeform, }, }) .collect(), @@ -390,22 +315,20 @@ fn cell_request(request: ExecuteRequest) -> CellRequest { } } -fn wait_for_event( - cell_id: CellId, - event: CellEventFuture, -) -> CodeModeSessionResultFuture<'static, WaitOutcome> { - Box::pin(async move { - match event.await { - Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), - Err(CellError::Closed) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), - Err(error) => Err(cell_error_text(&cell_id, error)), - } - }) +fn runtime_cell_id(cell_id: &CellId) -> runtime::CellId { + runtime::CellId::new(cell_id.as_str()) } -fn pending_outcome(cell_id: &CellId, event: CellEvent) -> Result { +fn protocol_cell_id(cell_id: &runtime::CellId) -> CellId { + CellId::new(cell_id.as_str().to_string()) +} + +fn pending_outcome( + cell_id: &CellId, + event: runtime::CellEvent, +) -> Result { match event { - CellEvent::Pending { + runtime::CellEvent::Pending { content_items, pending_tool_call_ids, } => Ok(ExecuteToPendingOutcome::Pending { @@ -419,13 +342,16 @@ fn pending_outcome(cell_id: &CellId, event: CellEvent) -> Result Result { +fn runtime_response( + cell_id: &CellId, + event: runtime::CellEvent, +) -> Result { match event { - CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { + runtime::CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { cell_id: cell_id.clone(), content_items: content_items.into_iter().map(output_item).collect(), }), - CellEvent::Completed { + runtime::CellEvent::Completed { content_items, error_text, } => Ok(RuntimeResponse::Result { @@ -433,36 +359,30 @@ fn runtime_response(cell_id: &CellId, event: CellEvent) -> Result Ok(RuntimeResponse::Terminated { + runtime::CellEvent::Terminated { content_items } => Ok(RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: content_items.into_iter().map(output_item).collect(), }), - CellEvent::Pending { .. } => { + runtime::CellEvent::Pending { .. } => { Err("cell returned a pending frontier unexpectedly".to_string()) } } } -fn output_item(item: CellOutputItem) -> FunctionCallOutputContentItem { +fn output_item(item: runtime::OutputItem) -> FunctionCallOutputContentItem { match item { - CellOutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text }, - CellOutputItem::Image { image_url, detail } => FunctionCallOutputContentItem::InputImage { - image_url, - detail: detail.map(|detail| match detail { - CellImageDetail::Auto => ImageDetail::Auto, - CellImageDetail::Low => ImageDetail::Low, - CellImageDetail::High => ImageDetail::High, - CellImageDetail::Original => ImageDetail::Original, - }), - }, - } -} - -fn cell_error_text(cell_id: &CellId, error: CellError) -> String { - match error { - CellError::Busy => format!("exec cell {cell_id} already has an active observer"), - CellError::AlreadyTerminating => format!("exec cell {cell_id} is already terminating"), - CellError::Closed => format!("exec cell {cell_id} closed unexpectedly"), + runtime::OutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text }, + runtime::OutputItem::Image { image_url, detail } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.map(|detail| match detail { + runtime::ImageDetail::Auto => ImageDetail::Auto, + runtime::ImageDetail::Low => ImageDetail::Low, + runtime::ImageDetail::High => ImageDetail::High, + runtime::ImageDetail::Original => ImageDetail::Original, + }), + } + } } } diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs index 3d814e98cc..8ead39640a 100644 --- a/codex-rs/code-mode/src/service_contract_tests.rs +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -288,8 +288,23 @@ async fn observed_natural_completion_wins_over_termination() { ); tokio::time::timeout(Duration::from_secs(1), async { loop { - if service.inner.stored_values.lock().await.get("finished") - == Some(&serde_json::json!(true)) + let response = service + .execute(ExecuteRequest { + yield_time_ms: Some(60_000), + ..execute_request(r#"text(String(load("finished")));"#) + }) + .await + .unwrap() + .initial_response() + .await + .unwrap(); + let RuntimeResponse::Result { content_items, .. } = response else { + panic!("expected stored-value probe to complete"); + }; + if content_items + == vec![FunctionCallOutputContentItem::InputText { + text: "true".to_string(), + }] { break; } diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode/src/service_tests.rs index 688bd89cd2..6344bfdab9 100644 --- a/codex-rs/code-mode/src/service_tests.rs +++ b/codex-rs/code-mode/src/service_tests.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::sync::atomic::Ordering; use std::time::Duration; use super::CellId; @@ -7,7 +6,6 @@ use super::CodeModeNestedToolCall; use super::CodeModeService; use super::CodeModeSessionDelegate; use super::NotificationFuture; -use super::ObserveMode; use super::RuntimeResponse; use super::ToolInvocationFuture; use super::WaitOutcome; @@ -216,20 +214,15 @@ async fn shutdown_interrupts_cpu_bound_cells() { #[tokio::test] async fn start_cell_rejects_new_cell_after_shutdown_begins() { let service = CodeModeService::new(); - service.inner.shutting_down.store(true, Ordering::Release); + service.shutdown().await.unwrap(); let error = service - .start_cell( - cell_id("late-cell"), - execute_request(""), - ObserveMode::YieldAfter(Duration::from_millis(1)), - ) + .execute(execute_request("text('late');")) .await .err() .unwrap(); assert_eq!(error, "code mode session is shutting down".to_string()); - assert!(service.inner.cells.lock().await.is_empty()); } #[tokio::test] @@ -367,7 +360,7 @@ await Promise.all([ } ); - tokio::time::sleep(Duration::from_millis(1100)).await; + tokio::time::sleep(Duration::from_secs(2)).await; let resumed_response = tokio::time::timeout( Duration::from_secs(1), diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs new file mode 100644 index 0000000000..0f7557a750 --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -0,0 +1,295 @@ +mod types; + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; + +pub(crate) use self::types::CellEvent; +pub(crate) use self::types::CellId; +pub(crate) use self::types::CreateCellRequest; +pub(crate) use self::types::Error; +pub(crate) use self::types::ImageDetail; +pub(crate) use self::types::NestedToolCall; +pub(crate) use self::types::ObserveMode; +pub(crate) use self::types::OutputItem; +pub(crate) use self::types::SessionRuntimeDelegate; +pub(crate) use self::types::ToolDefinition; +pub(crate) use self::types::ToolKind; +pub(crate) use self::types::ToolName; +use crate::cell_actor::CellActor; +use crate::cell_actor::CellError; +use crate::cell_actor::CellEventFuture; +use crate::cell_actor::CellHandle; +use crate::cell_actor::CellHost; +use crate::cell_actor::CellState; +use crate::cell_actor::CellToolCall; +use crate::cell_actor::CompletionCommit; + +type RuntimeEventFuture = Pin> + Send + 'static>>; + +/// Owns all cells and shared state for one transport-neutral code-mode session. +pub(crate) struct SessionRuntime { + inner: Arc>, +} + +struct Inner { + stored_values: Mutex>, + cells: Mutex>, + cell_tasks: TaskTracker, + shutdown_token: CancellationToken, + delegate: Arc, + next_cell_id: AtomicU64, +} + +impl SessionRuntime { + pub(crate) fn new(delegate: Arc) -> Self { + Self { + inner: Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + cells: Mutex::new(HashMap::new()), + cell_tasks: TaskTracker::new(), + shutdown_token: CancellationToken::new(), + delegate, + next_cell_id: AtomicU64::new(1), + }), + } + } + + pub(crate) fn is_alive(&self) -> bool { + !self.inner.shutdown_token.is_cancelled() + } + + pub(crate) async fn execute( + &self, + request: CreateCellRequest, + initial_observe_mode: ObserveMode, + ) -> Result { + if self.inner.shutdown_token.is_cancelled() { + return Err(Error::ShuttingDown); + } + let cell_id = self.allocate_cell_id(); + let initial_event = self + .start_cell(cell_id.clone(), request, initial_observe_mode) + .await?; + Ok(StartedCell { + cell_id, + initial_event, + }) + } + + pub(crate) async fn observe( + &self, + cell_id: &CellId, + mode: ObserveMode, + ) -> Result { + self.begin_observe(cell_id, mode).await?.event().await + } + + pub(crate) async fn begin_observe( + &self, + cell_id: &CellId, + mode: ObserveMode, + ) -> Result { + let handle = self + .inner + .cells + .lock() + .await + .get(cell_id) + .cloned() + .ok_or_else(|| Error::MissingCell(cell_id.clone()))?; + Ok(PendingEvent { + event: map_actor_event(cell_id.clone(), handle.observe(mode)), + }) + } + + pub(crate) async fn terminate(&self, cell_id: &CellId) -> Result { + let handle = self + .inner + .cells + .lock() + .await + .get(cell_id) + .cloned() + .ok_or_else(|| Error::MissingCell(cell_id.clone()))?; + handle + .terminate() + .await + .map_err(|error| actor_error(cell_id, error)) + } + + pub(crate) async fn shutdown(&self) -> Result<(), Error> { + self.begin_shutdown(); + // Taking the registry lock ensures every cell that passed the shutdown + // check has registered its actor with the tracker before we wait. + let cells = self.inner.cells.lock().await; + self.inner.cell_tasks.close(); + drop(cells); + self.inner.cell_tasks.wait().await; + Ok(()) + } + + fn allocate_cell_id(&self) -> CellId { + CellId::new( + self.inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(), + ) + } + + async fn start_cell( + &self, + cell_id: CellId, + request: CreateCellRequest, + initial_observe_mode: ObserveMode, + ) -> Result { + let stored_values = self.inner.stored_values.lock().await.clone(); + let host = Arc::new(RuntimeCellHost { + cell_id: cell_id.clone(), + inner: Arc::clone(&self.inner), + }); + let mut cells = self.inner.cells.lock().await; + if self.inner.shutdown_token.is_cancelled() { + return Err(Error::ShuttingDown); + } + if cells.contains_key(&cell_id) { + return Err(Error::DuplicateCell(cell_id)); + } + let cell_state = Arc::new(CellState::new(self.inner.shutdown_token.child_token())); + let (handle, initial_event, task) = CellActor::prepare( + request, + stored_values, + host, + initial_observe_mode, + cell_state, + ) + .map_err(Error::Runtime)?; + cells.insert(cell_id.clone(), handle); + self.inner.cell_tasks.spawn(task); + drop(cells); + Ok(map_actor_event(cell_id, initial_event)) + } + + fn begin_shutdown(&self) { + self.inner.shutdown_token.cancel(); + self.inner.cell_tasks.close(); + } +} + +impl Drop for SessionRuntime { + fn drop(&mut self) { + self.begin_shutdown(); + } +} + +/// A cell admitted by [`SessionRuntime::execute`]. +pub(crate) struct StartedCell { + pub(crate) cell_id: CellId, + initial_event: RuntimeEventFuture, +} + +impl StartedCell { + pub(crate) async fn initial_event(self) -> Result { + self.initial_event.await + } +} + +/// An admitted observation that has not reached its requested frontier yet. +pub(crate) struct PendingEvent { + event: RuntimeEventFuture, +} + +impl PendingEvent { + pub(crate) async fn event(self) -> Result { + self.event.await + } +} + +struct RuntimeCellHost { + cell_id: CellId, + inner: Arc>, +} + +impl CellHost for RuntimeCellHost { + async fn invoke_tool( + &self, + invocation: CellToolCall, + cancellation_token: CancellationToken, + ) -> Result { + self.inner + .delegate + .invoke_tool( + NestedToolCall { + cell_id: self.cell_id.clone(), + runtime_tool_call_id: invocation.id, + tool_name: invocation.name, + tool_kind: invocation.kind, + input: invocation.input, + }, + cancellation_token, + ) + .await + } + + async fn notify( + &self, + call_id: String, + text: String, + cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.inner + .delegate + .notify(call_id, self.cell_id.clone(), text, cancellation_token) + .await + } + + async fn commit_completion( + &self, + stored_value_writes: HashMap, + event: CellEvent, + pending_initial_yield_items: Option>, + cell_state: Arc, + ) -> CompletionCommit { + let cancellation_token = cell_state.cancellation_token(); + let mut stored_values = tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + return CompletionCommit::Rejected(event); + } + stored_values = self.inner.stored_values.lock() => stored_values, + }; + cell_state.commit_completion(event, pending_initial_yield_items, || { + stored_values.extend(stored_value_writes); + }) + } + + async fn closed(&self) { + self.inner.cells.lock().await.remove(&self.cell_id); + self.inner.delegate.cell_closed(&self.cell_id); + } +} + +fn map_actor_event(cell_id: CellId, event: CellEventFuture) -> RuntimeEventFuture { + Box::pin(async move { event.await.map_err(|error| actor_error(&cell_id, error)) }) +} + +fn actor_error(cell_id: &CellId, error: CellError) -> Error { + match error { + CellError::Busy => Error::BusyObserver(cell_id.clone()), + CellError::AlreadyTerminating => Error::AlreadyTerminating(cell_id.clone()), + CellError::Closed => Error::ClosedCell(cell_id.clone()), + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode/src/session_runtime/tests.rs new file mode 100644 index 0000000000..e0838d89d8 --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/tests.rs @@ -0,0 +1,188 @@ +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; +use std::time::Duration; + +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; + +use super::*; +use crate::cell_actor::CompletionCommit; + +struct RecordingDelegate; + +impl SessionRuntimeDelegate for RecordingDelegate { + async fn invoke_tool( + &self, + _invocation: NestedToolCall, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(JsonValue::Null) + } + + async fn notify( + &self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> Result<(), String> { + Ok(()) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + +#[tokio::test] +async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_load_it() { + let runtime = SessionRuntime::new(Arc::new(RecordingDelegate)); + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let host = RuntimeCellHost { + cell_id: CellId::new("terminating-writer"), + inner: Arc::clone(&runtime.inner), + }; + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "uncommitted output".to_string(), + }], + error_text: None, + }; + + let stored_values = runtime.inner.stored_values.lock().await; + let commit = host.commit_completion( + HashMap::from([( + "candidate".to_string(), + JsonValue::String("lost".to_string()), + )]), + completion.clone(), + /*pending_initial_yield_items*/ None, + Arc::clone(&cell_state), + ); + tokio::pin!(commit); + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + assert!(matches!(commit.as_mut().poll(&mut context), Poll::Pending)); + + let termination = cell_state.request_termination(); + drop(stored_values); + assert_eq!(commit.await, CompletionCommit::Rejected(completion)); + let terminated = CellEvent::Terminated { + content_items: Vec::new(), + }; + assert_eq!( + cell_state.finish_termination(terminated.clone()), + Some(terminated.clone()) + ); + assert_eq!(termination.await, Ok(terminated)); + assert!( + !runtime + .inner + .stored_values + .lock() + .await + .contains_key("candidate") + ); + + let reader = runtime + .execute( + CreateCellRequest { + tool_call_id: "reader".to_string(), + enabled_tools: Vec::new(), + source: r#"text(String(load("candidate")));"#.to_string(), + }, + ObserveMode::YieldAfter(Duration::from_secs(1)), + ) + .await + .unwrap(); + assert_eq!( + reader.initial_event().await, + Ok(CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "undefined".to_string(), + }], + error_text: None, + }) + ); + runtime.shutdown().await.unwrap(); +} + +fn execute_request(source: &str) -> CreateCellRequest { + CreateCellRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + } +} + +#[tokio::test] +#[expect( + clippy::await_holding_invalid_type, + reason = "test holds the registry lock to force admission ahead of shutdown" +)] +async fn shutdown_rejects_cell_admission_queued_before_the_registry_lock() { + let runtime = Arc::new(SessionRuntime::new(Arc::new(RecordingDelegate))); + let cells = runtime.inner.cells.lock().await; + + let execution = runtime.execute( + execute_request("while (true) {}"), + ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)), + ); + tokio::pin!(execution); + std::future::poll_fn(|context| match execution.as_mut().poll(context) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(Ok(_)) => panic!("execution completed before the registry lock was released"), + Poll::Ready(Err(error)) => { + panic!("execution failed before the registry lock was released: {error}") + } + }) + .await; + + let shutdown = runtime.shutdown(); + tokio::pin!(shutdown); + std::future::poll_fn(|context| match shutdown.as_mut().poll(context) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(Ok(())) => panic!("shutdown completed before acquiring the registry lock"), + Poll::Ready(Err(error)) => { + panic!("shutdown failed before acquiring the registry lock: {error}") + } + }) + .await; + + assert!(!runtime.is_alive()); + drop(cells); + assert!(matches!(execution.await, Err(Error::ShuttingDown))); + assert_eq!(shutdown.await, Ok(())); +} + +#[tokio::test] +async fn drop_terminates_cells_when_the_registry_is_locked() { + let runtime = SessionRuntime::new(Arc::new(RecordingDelegate)); + let started = runtime + .execute( + execute_request("while (true) {}"), + ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)), + ) + .await + .unwrap(); + assert_eq!(started.cell_id, CellId::new("1")); + assert_eq!( + started.initial_event().await, + Ok(CellEvent::Yielded { + content_items: Vec::new(), + }) + ); + + let inner = Arc::clone(&runtime.inner); + let cells = inner.cells.lock().await; + drop(runtime); + drop(cells); + + tokio::time::timeout(Duration::from_secs(/*secs*/ 1), inner.cell_tasks.wait()) + .await + .unwrap(); + assert!(inner.cell_tasks.is_empty()); +} diff --git a/codex-rs/code-mode/src/session_runtime/types.rs b/codex-rs/code-mode/src/session_runtime/types.rs new file mode 100644 index 0000000000..b04bd0f236 --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/types.rs @@ -0,0 +1,172 @@ +use std::fmt; +use std::future::Future; +use std::time::Duration; + +use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; + +/// Identifies one execution cell within a session runtime. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct CellId(String); + +impl CellId { + pub(crate) fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CellId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Selects the next observable frontier for a running cell. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ObserveMode { + YieldAfter(Duration), + PendingFrontier, +} + +/// An observable cell lifecycle event. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum CellEvent { + Yielded { + content_items: Vec, + }, + Pending { + content_items: Vec, + pending_tool_call_ids: Vec, + }, + Completed { + content_items: Vec, + error_text: Option, + }, + Terminated { + content_items: Vec, + }, +} + +/// Output emitted by a cell since its preceding observation. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum OutputItem { + Text { + text: String, + }, + Image { + image_url: String, + detail: Option, + }, +} + +/// Requested image fidelity for an output image. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ImageDetail { + Auto, + Low, + High, + Original, +} + +/// Transport-neutral input for creating a cell. +/// +/// The owning session assigns the cell ID when it admits the request. +pub(crate) struct CreateCellRequest { + pub(crate) tool_call_id: String, + pub(crate) enabled_tools: Vec, + pub(crate) source: String, +} + +/// Tool metadata exposed to code running inside a cell. +pub(crate) struct ToolDefinition { + pub(crate) name: String, + pub(crate) tool_name: ToolName, + pub(crate) description: String, + pub(crate) kind: ToolKind, +} + +/// A tool name with an optional namespace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ToolName { + pub(crate) name: String, + pub(crate) namespace: Option, +} + +/// The JavaScript calling convention for a tool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolKind { + Function, + Freeform, +} + +/// A nested tool request emitted by a running cell. +pub(crate) struct NestedToolCall { + pub(crate) cell_id: CellId, + pub(crate) runtime_tool_call_id: String, + pub(crate) tool_name: ToolName, + pub(crate) tool_kind: ToolKind, + pub(crate) input: Option, +} + +/// Host callbacks used by cells owned by a [`super::SessionRuntime`]. +/// +/// Implementations must honor cancellation tokens. `cell_closed` is called +/// after the runtime has stopped routing requests to the cell. +pub(crate) trait SessionRuntimeDelegate: Send + Sync + 'static { + fn invoke_tool( + &self, + invocation: NestedToolCall, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn notify( + &self, + call_id: String, + cell_id: CellId, + text: String, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn cell_closed(&self, cell_id: &CellId); +} + +/// A failure reported by a session runtime operation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Error { + ShuttingDown, + DuplicateCell(CellId), + MissingCell(CellId), + BusyObserver(CellId), + AlreadyTerminating(CellId), + ClosedCell(CellId), + Runtime(String), +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ShuttingDown => formatter.write_str("code mode session is shutting down"), + Self::DuplicateCell(cell_id) => write!(formatter, "exec cell {cell_id} already exists"), + Self::MissingCell(cell_id) => write!(formatter, "exec cell {cell_id} not found"), + Self::BusyObserver(cell_id) => { + write!( + formatter, + "exec cell {cell_id} already has an active observer" + ) + } + Self::AlreadyTerminating(cell_id) => { + write!(formatter, "exec cell {cell_id} is already terminating") + } + Self::ClosedCell(cell_id) => { + write!(formatter, "exec cell {cell_id} closed unexpectedly") + } + Self::Runtime(error_text) => formatter.write_str(error_text), + } + } +} + +impl std::error::Error for Error {} diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 1c34d8bbf2..ab1b409eb1 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -23,15 +23,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr { ApiError::Retryable { message, delay } => CodexErr::Stream(message, delay), ApiError::Stream(msg) => CodexErr::Stream(msg, None), ApiError::ServerOverloaded => CodexErr::ServerOverloaded, - ApiError::Api { status, message } => CodexErr::UnexpectedStatus(UnexpectedResponseError { - status, - body: message, - url: None, - cf_ray: None, - request_id: None, - identity_authorization_error: None, - identity_error_code: None, - }), + ApiError::Api { status, message } => { + let user_message = api_error_user_message(status, &message); + CodexErr::UnexpectedStatus(UnexpectedResponseError { + status, + body: message, + user_message, + url: None, + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }) + } ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message }, ApiError::Transport(transport) => match transport { @@ -111,6 +115,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { } else { CodexErr::UnexpectedStatus(UnexpectedResponseError { status, + user_message: api_error_user_message(status, &body_text), body: body_text, url, cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER), @@ -145,6 +150,8 @@ const X_ERROR_JSON_HEADER: &str = "x-error-json"; const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy"; const CYBER_POLICY_FALLBACK_MESSAGE: &str = "This request has been flagged for possible cybersecurity risk."; +const CLOUDFLARE_BLOCKED_MESSAGE: &str = + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region"; #[cfg(test)] #[path = "api_bridge_tests.rs"] @@ -154,6 +161,17 @@ fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option { extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER)) } +fn api_error_user_message(status: http::StatusCode, body: &str) -> Option { + if status == http::StatusCode::FORBIDDEN + && body.contains("Cloudflare") + && body.contains("blocked") + { + Some(format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})")) + } else { + None + } +} + fn extract_request_id(headers: Option<&HeaderMap>) -> Option { extract_header(headers, REQUEST_ID_HEADER) .or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER)) diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 101e5566fe..5812fc19fe 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -26,6 +26,34 @@ fn map_api_error_maps_server_overloaded_from_503_body() { assert!(matches!(err, CodexErr::ServerOverloaded)); } +#[test] +fn map_api_error_maps_cloudflare_blocked_response_to_user_message() { + let mut headers = HeaderMap::new(); + headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-id")); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::FORBIDDEN, + url: Some("http://example.com/blocked".to_string()), + headers: Some(headers), + body: Some( + "Cloudflare error: Sorry, you have been blocked".to_string(), + ), + })); + + let CodexErr::UnexpectedStatus(err) = err else { + panic!("expected CodexErr::UnexpectedStatus, got {err:?}"); + }; + assert_eq!( + err.user_message.as_deref(), + Some( + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden)" + ) + ); + assert_eq!( + err.to_string(), + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden), url: http://example.com/blocked, cf-ray: ray-id" + ); +} + #[test] fn map_api_error_maps_cyber_policy_from_400_body() { let body = serde_json::json!({ diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 17753e799a..bd037deb58 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -28,7 +28,8 @@ pub struct CompactionInput<'a> { pub input: &'a [ResponseItem], #[serde(skip_serializing_if = "str::is_empty")] pub instructions: &'a str, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub parallel_tool_calls: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, @@ -72,6 +73,7 @@ pub struct MemorySummarizeOutput { #[derive(Debug)] pub enum ResponseEvent { Created, + SafetyBuffering(SafetyBuffering), OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), /// Emitted when the server includes `OpenAI-Model` on the stream response. @@ -113,6 +115,30 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct SafetyBuffering { + pub use_cases: Vec, + pub reasons: Vec, + #[serde(skip)] + pub show_buffering_ui: bool, + #[serde(skip)] + pub faster_model: Option, +} + +impl SafetyBuffering { + pub(crate) fn with_treatment(mut self, treatment: &SafetyBufferingTreatment) -> Self { + self.show_buffering_ui = treatment.show_buffering_ui; + self.faster_model.clone_from(&treatment.faster_model); + self + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct SafetyBufferingTreatment { + pub show_buffering_ui: bool, + pub faster_model: Option, +} + #[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ReasoningContext { @@ -185,7 +211,8 @@ pub struct ResponsesApiRequest { #[serde(skip_serializing_if = "String::is_empty")] pub instructions: String, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, @@ -233,7 +260,8 @@ pub struct ResponseCreateWsRequest { #[serde(skip_serializing_if = "Option::is_none")] pub previous_response_id: Option, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 4ea564f139..755228a9f5 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -2,9 +2,11 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; +use crate::common::SafetyBufferingTreatment; use crate::error::ApiError; use crate::provider::Provider; use crate::rate_limits::parse_rate_limit_event; +use crate::safety_buffering::treatment_from_headers; use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; @@ -595,12 +597,12 @@ fn map_wrapped_websocket_error_event( Some(ApiError::Transport(TransportError::Http { status, url: None, - headers: headers.map(json_headers_to_http_headers), + headers: headers.as_ref().map(json_headers_to_http_headers), body: Some(original_payload), })) } -fn json_headers_to_http_headers(headers: JsonMap) -> HeaderMap { +fn json_headers_to_http_headers(headers: &JsonMap) -> HeaderMap { let mut mapped = HeaderMap::new(); for (name, value) in headers { let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { @@ -614,9 +616,9 @@ fn json_headers_to_http_headers(headers: JsonMap) -> HeaderMap { mapped } -fn json_header_value(value: Value) -> Option { +fn json_header_value(value: &Value) -> Option { let value = match value { - Value::String(value) => value, + Value::String(value) => value.clone(), Value::Number(value) => value.to_string(), Value::Bool(value) => value.to_string(), _ => return None, @@ -634,6 +636,7 @@ async fn run_websocket_response_stream( turn_state: Option<&OnceLock>, ) -> Result<(), ApiError> { let mut last_server_model: Option = None; + let mut safety_buffering_treatment = SafetyBufferingTreatment::default(); send_websocket_request( ws_stream, request_text, @@ -668,7 +671,6 @@ async fn run_websocket_response_stream( match message { Message::Text(text) => { - trace!("websocket event: {text}"); if let Some(wrapped_error) = parse_wrapped_websocket_error_event(&text) && let Some(error) = map_wrapped_websocket_error_event(wrapped_error, text.to_string()) @@ -688,8 +690,17 @@ async fn run_websocket_response_stream( { let _ = turn_state.set(response_turn_state); } + if let Some(headers) = event.headers.as_ref().and_then(Value::as_object) + && let Some(treatment) = + treatment_from_headers(&json_headers_to_http_headers(headers)) + { + safety_buffering_treatment = treatment; + } let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); + let safety_buffering = event + .safety_buffering() + .map(|buffering| buffering.with_treatment(&safety_buffering_treatment)); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -724,6 +735,16 @@ async fn run_websocket_response_stream( "response event consumer dropped".to_string(), )); } + if let Some(buffering) = safety_buffering + && tx_event + .send(Ok(ResponseEvent::SafetyBuffering(buffering))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); @@ -815,13 +836,13 @@ mod tests { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], - tools: vec![json!({ + tools: Some(vec![json!({ "type": "function", "name": "lookup", "parameters": {"type": "object"} - })], + })]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 93f6249f10..d33e502ce3 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -162,7 +162,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }])), commands: Some(SearchCommands { search_query: Some(vec![SearchQuery { diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 4881e77fbc..1e7294be8f 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -8,6 +8,7 @@ pub(crate) mod images; pub(crate) mod provider; pub(crate) mod rate_limits; pub(crate) mod requests; +pub(crate) mod safety_buffering; pub(crate) mod search; pub(crate) mod sse; pub(crate) mod telemetry; diff --git a/codex-rs/codex-api/src/safety_buffering.rs b/codex-rs/codex-api/src/safety_buffering.rs new file mode 100644 index 0000000000..e5f3dcd79e --- /dev/null +++ b/codex-rs/codex-api/src/safety_buffering.rs @@ -0,0 +1,54 @@ +use crate::common::SafetyBufferingTreatment; +use http::HeaderMap; + +pub(crate) const X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER: &str = "x-codex-safety-buffering-enabled"; +pub(crate) const X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER: &str = + "x-codex-safety-buffering-faster-model"; + +pub(crate) fn treatment_from_headers(headers: &HeaderMap) -> Option { + let show_buffering_ui = headers + .get(X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER) + .and_then(|value| value.to_str().ok())? + .eq_ignore_ascii_case("true"); + let faster_model = if show_buffering_ui { + headers + .get(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + } else { + None + }; + + Some(SafetyBufferingTreatment { + show_buffering_ui, + faster_model, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderValue; + use pretty_assertions::assert_eq; + + #[test] + fn reads_treatment_from_http_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER, + HeaderValue::from_static("true"), + ); + headers.insert( + X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER, + HeaderValue::from_static("faster-model"), + ); + + assert_eq!( + treatment_from_headers(&headers), + Some(SafetyBufferingTreatment { + show_buffering_ui: true, + faster_model: Some("faster-model".to_string()), + }) + ); + } +} diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index a451f67af0..71f73c0232 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -1,7 +1,10 @@ use crate::common::ResponseEvent; use crate::common::ResponseStream; +use crate::common::SafetyBuffering; +use crate::common::SafetyBufferingTreatment; use crate::error::ApiError; use crate::rate_limits::parse_all_rate_limits; +use crate::safety_buffering::treatment_from_headers; use crate::telemetry::SseTelemetry; use codex_client::ByteStream; use codex_client::StreamResponse; @@ -54,6 +57,8 @@ pub fn spawn_response_stream( .get(REQUEST_ID_HEADER) .and_then(|value| value.to_str().ok()) .map(str::to_string); + let safety_buffering_treatment = + treatment_from_headers(&stream_response.headers).unwrap_or_default(); if let Some(turn_state) = turn_state.as_ref() && let Some(header_value) = stream_response .headers @@ -78,7 +83,14 @@ pub fn spawn_response_stream( .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) .await; } - process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await; + process_sse_with_treatment( + stream_response.bytes, + tx_event, + idle_timeout, + telemetry, + safety_buffering_treatment, + ) + .await; }); ResponseStream { @@ -148,7 +160,7 @@ struct ResponseCompletedOutputTokensDetails { pub struct ResponsesStreamEvent { #[serde(rename = "type")] pub(crate) kind: String, - headers: Option, + pub(crate) headers: Option, metadata: Option, response: Option, item: Option, @@ -157,6 +169,7 @@ pub struct ResponsesStreamEvent { delta: Option, summary_index: Option, content_index: Option, + safety_buffering: Option, } impl ResponsesStreamEvent { @@ -217,6 +230,10 @@ impl ResponsesStreamEvent { .cloned() .map(|metadata| TurnModerationMetadataEvent { metadata }) } + + pub(crate) fn safety_buffering(&self) -> Option { + serde_json::from_value(self.safety_buffering.as_ref()?.clone()).ok() + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -431,11 +448,29 @@ pub fn process_responses_event( Ok(None) } +#[cfg(test)] pub async fn process_sse( stream: ByteStream, tx_event: mpsc::Sender>, idle_timeout: Duration, telemetry: Option>, +) { + process_sse_with_treatment( + stream, + tx_event, + idle_timeout, + telemetry, + SafetyBufferingTreatment::default(), + ) + .await; +} + +async fn process_sse_with_treatment( + stream: ByteStream, + tx_event: mpsc::Sender>, + idle_timeout: Duration, + telemetry: Option>, + safety_buffering_treatment: SafetyBufferingTreatment, ) { let mut stream = stream.eventsource(); let mut response_error: Option = None; @@ -480,6 +515,9 @@ pub async fn process_sse( }; let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); + let safety_buffering = event + .safety_buffering() + .map(|buffering| buffering.with_treatment(&safety_buffering_treatment)); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -509,6 +547,14 @@ pub async fn process_sse( { return; } + if let Some(buffering) = safety_buffering + && tx_event + .send(Ok(ResponseEvent::SafetyBuffering(buffering))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -1294,6 +1340,64 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_all_safety_buffering_notifications_without_dropping_response_events() + { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { "id": "resp-1" }, + "safety_buffering": false + }), + json!({ + "type": "response.output_text.delta", + "delta": "hello", + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + json!({ + "type": "response.output_text.delta", + "delta": " world", + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + json!({ + "type": "response.completed", + "response": { "id": "resp-1" }, + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + ]) + .await; + + assert_eq!(events.len(), 7); + assert_matches!(&events[0], ResponseEvent::Created); + assert_matches!( + &events[1], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[2], ResponseEvent::OutputTextDelta(delta) if delta == "hello"); + assert_matches!( + &events[3], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[4], ResponseEvent::OutputTextDelta(delta) if delta == " world"); + assert_matches!( + &events[5], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[6], ResponseEvent::Completed { response_id, .. } if response_id == "resp-1"); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 488f6dc674..60a9852cfa 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -313,9 +313,9 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, @@ -401,7 +401,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { model: "gpt-test".into(), instructions: "Say hi".into(), input: Vec::new(), - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, @@ -518,9 +518,9 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 87f95de655..fcd97b6bda 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -24,6 +24,18 @@ tracing-opentelemetry = { workspace = true } codex-utils-rustls-provider = { workspace = true } zstd = { workspace = true } +[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] +sha2 = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +system-configuration = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_Networking_WinHttp", +] } + [lints] workspace = true diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index e7c5edfc7d..78daa6c288 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -3,6 +3,7 @@ mod chatgpt_hosts; mod custom_ca; mod default_client; mod error; +mod outbound_proxy; mod request; mod retry; mod sse; @@ -25,6 +26,11 @@ pub use crate::default_client::CodexHttpClient; pub use crate::default_client::CodexRequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError; +pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; +pub use crate::outbound_proxy::ClientRouteClass; +pub use crate::outbound_proxy::OutboundProxyConfig; +pub use crate::outbound_proxy::RouteFailureClass; +pub use crate::outbound_proxy::build_reqwest_client_for_route; pub use crate::request::EncodedJsonBody; pub use crate::request::PreparedRequestBody; pub use crate::request::Request; diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs new file mode 100644 index 0000000000..ea6587052e --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -0,0 +1,556 @@ +//! Conservative outbound proxy selection for resolver-aware clients. +//! +//! When enabled, platform system discovery is tried first, explicit environment +//! proxies are the fallback, and the final fallback is a direct connection. +//! When disabled, callers retain the existing reqwest builder behavior. + +use std::collections::HashMap; +use std::fmt; +use std::io; +use std::sync::Mutex; +use std::sync::OnceLock; +use std::time::Duration; +use std::time::Instant; + +use crate::custom_ca::BuildCustomCaTransportError; +use crate::custom_ca::build_reqwest_client_with_custom_ca; +#[cfg(any(target_os = "windows", target_os = "macos"))] +use sha2::Digest; +#[cfg(any(target_os = "windows", target_os = "macos"))] +use sha2::Sha256; +use thiserror::Error; + +const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60); +const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5); +const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +/// Coarse semantic bucket for the HTTP or WebSocket client being constructed. +/// +/// This is not the selected proxy route or a concrete endpoint. It labels the +/// product path that owns the client so proxy-resolution diagnostics can +/// distinguish auth, API, WebSocket, and miscellaneous traffic without exposing +/// endpoint details. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientRouteClass { + /// Login, token refresh/revoke, PAT, and agent identity auth traffic. + Auth, + /// First-party API traffic that is not part of the auth flow. + Api, + /// WebSocket traffic. + WebSocket, + /// Call sites without a more specific route class. + Other, +} + +impl fmt::Display for ClientRouteClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Auth => "auth", + Self::Api => "api", + Self::WebSocket => "wss", + Self::Other => "other", + }) + } +} + +/// Coarse failure class for route selection errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteFailureClass { + ProxyResolutionUnavailable, + ConnectTimeout, + ProxyAuthenticationRequired, + TlsError, + InvalidProxyConfig, + UnsupportedProxyScheme, + ResolverError, +} + +impl fmt::Display for RouteFailureClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable", + Self::ConnectTimeout => "connect_timeout", + Self::ProxyAuthenticationRequired => "proxy_407", + Self::TlsError => "tls_error", + Self::InvalidProxyConfig => "invalid_proxy_config", + Self::UnsupportedProxyScheme => "unsupported_proxy_scheme", + Self::ResolverError => "resolver_error", + }) + } +} + +/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing. +/// Resolved endpoints and platform details remain internal to the client builder. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutboundProxyConfig; + +impl OutboundProxyConfig { + pub const fn respect_system_proxy() -> Self { + Self + } +} + +/// Error while building a resolver-aware reqwest client. +#[derive(Debug, Error)] +pub enum BuildRouteAwareHttpClientError { + #[error(transparent)] + CustomCa(#[from] BuildCustomCaTransportError), + + #[error("Failed to configure outbound proxy selected for {route_class}")] + InvalidProxyConfig { route_class: ClientRouteClass }, +} + +impl From for io::Error { + fn from(error: BuildRouteAwareHttpClientError) -> Self { + match error { + BuildRouteAwareHttpClientError::CustomCa(error) => error.into(), + BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } => io::Error::other(error), + } + } +} + +/// Builds a reqwest client with conservative route selection and shared CA handling. +/// +/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after +/// a route is selected are returned without trying another route. Ordered PAC candidates are +/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates +/// are not retried after a connection failure. +pub fn build_reqwest_client_for_route( + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + let builder = configure_proxy_for_route( + &ProcessEnv, + builder, + request_url, + route_class, + config, + resolve_system_proxy, + )?; + build_reqwest_client_with_custom_ca(builder).map_err(Into::into) +} + +fn configure_proxy_for_route( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, + resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, +) -> Result { + if config.is_none() { + return Ok(builder); + } + let origin = RequestOrigin::parse(request_url); + + let Some(origin) = origin.as_ref() else { + return configure_env_proxy_handling(env, builder, /*origin*/ None, route_class); + }; + + match resolve_system_proxy(request_url, origin) { + SystemProxyDecision::Direct => Ok(builder.no_proxy()), + SystemProxyDecision::Proxy { url } => { + configure_concrete_proxy(builder, route_class, &url, /*no_proxy*/ None) + } + SystemProxyDecision::Unavailable { .. } => { + configure_env_proxy_handling(env, builder, Some(origin), route_class) + } + } +} + +fn configure_concrete_proxy( + builder: reqwest::ClientBuilder, + route_class: ClientRouteClass, + proxy_url: &str, + no_proxy: Option, +) -> Result { + let proxy = match reqwest::Proxy::all(proxy_url) { + Ok(proxy) => proxy, + Err(_source) => { + return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class }); + } + }; + Ok(builder.proxy(proxy.no_proxy(no_proxy))) +} + +fn configure_env_proxy_handling( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + origin: Option<&RequestOrigin>, + route_class: ClientRouteClass, +) -> Result { + if let Some(origin) = origin { + let proxy_url = match origin.scheme.as_str() { + "https" => { + proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + "http" => { + proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + _ => proxy_env_value(env, "ALL_PROXY"), + }; + if let Some(proxy_url) = proxy_url { + let no_proxy = proxy_env_value(env, "NO_PROXY") + .and_then(|value| reqwest::NoProxy::from_string(&value)); + return configure_concrete_proxy(builder, route_class, &proxy_url, no_proxy); + } + } + Ok(builder.no_proxy()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RequestOrigin { + scheme: String, + host: String, + port: u16, +} + +impl RequestOrigin { + fn parse(request_url: &str) -> Option { + let uri = request_url.parse::().ok()?; + let scheme = uri.scheme_str()?.to_ascii_lowercase(); + let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase(); + let port = uri.port_u16().or(match scheme.as_str() { + "http" => Some(80), + "https" => Some(443), + _ => None, + })?; + Some(Self { scheme, host, port }) + } +} + +#[cfg_attr( + not(any(target_os = "windows", target_os = "macos")), + allow( + dead_code, + reason = "Direct and Proxy are constructed only by platform-specific resolvers" + ) +)] +#[derive(Debug, Clone, PartialEq, Eq)] +enum SystemProxyDecision { + Direct, + Proxy { url: String }, + Unavailable { failure: RouteFailureClass }, +} + +fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + if let Some(decision) = cached_system_proxy_decision(request_url) { + return decision; + } + + let decision = resolve_platform_system_proxy(request_url, origin); + cache_system_proxy_decision(request_url, decision.clone()); + decision +} + +#[cfg(target_os = "macos")] +fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + macos::resolve(request_url, origin) +} + +#[cfg(target_os = "windows")] +fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + windows::resolve(request_url, origin) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn resolve_platform_system_proxy( + _request_url: &str, + _origin: &RequestOrigin, +) -> SystemProxyDecision { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } +} + +#[derive(Debug, Clone)] +struct CachedSystemProxyDecision { + decision: SystemProxyDecision, + expires_at: Instant, +} + +static SYSTEM_PROXY_CACHE: OnceLock>> = + OnceLock::new(); + +fn cached_system_proxy_decision(request_url: &str) -> Option { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().ok()?; + let key = system_proxy_cache_key(request_url); + let cached = cache.get(&key)?; + if cached.expires_at > Instant::now() { + return Some(cached.decision.clone()); + } + cache.remove(&key); + None +} + +fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut cache) = cache.lock() { + let cache_key = system_proxy_cache_key(request_url); + insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now()); + } +} + +fn insert_system_proxy_cache_entry( + cache: &mut HashMap, + cache_key: &str, + decision: SystemProxyDecision, + now: Instant, +) { + let ttl = match &decision { + SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => { + SYSTEM_PROXY_SUCCESS_CACHE_TTL + } + SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL, + }; + + cache.retain(|_, cached| cached.expires_at > now); + if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES + && !cache.contains_key(cache_key) + && let Some(cache_key_to_evict) = cache + .iter() + .min_by_key(|(_, cached)| cached.expires_at) + .map(|(cache_key, _)| cache_key.clone()) + { + cache.remove(&cache_key_to_evict); + } + cache.insert( + cache_key.to_string(), + CachedSystemProxyDecision { + decision, + expires_at: now + ttl, + }, + ); +} + +fn system_proxy_cache_key(request_url: &str) -> String { + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + // Keep URL-specific PAC decisions without retaining the raw routed URL. + let mut hasher = Sha256::new(); + hasher.update(b"system-proxy-cache-v1\0"); + hasher.update(request_url.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + request_url.to_string() +} + +#[cfg(any(test, target_os = "windows"))] +fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool { + no_proxy + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .any(|entry| no_proxy_entry_matches_origin(entry, origin)) +} + +#[cfg(any(test, target_os = "windows"))] +fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool { + if entry == "*" { + return true; + } + + let mut entry = entry + .strip_prefix("http://") + .or_else(|| entry.strip_prefix("https://")) + .unwrap_or(entry) + .trim_matches(['[', ']']) + .to_ascii_lowercase(); + let mut port = None; + let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| { + if host.contains(':') { + return None; + } + candidate_port + .parse::() + .ok() + .map(|parsed_port| (host.to_string(), parsed_port)) + }); + if let Some((host, parsed_port)) = parsed_host_port { + entry = host; + port = Some(parsed_port); + } + if port.is_some_and(|port| port != origin.port) { + return false; + } + + if let Some(suffix) = entry.strip_prefix('.') { + return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}")); + } + + if entry.contains('*') { + return wildcard_host_match(&entry, &origin.host); + } + + origin.host == entry +} + +#[cfg(any(test, target_os = "windows"))] +fn wildcard_host_match(pattern: &str, host: &str) -> bool { + let mut remaining = host; + let mut first = true; + for part in pattern.split('*') { + if part.is_empty() { + continue; + } + if first && !pattern.starts_with('*') { + let Some(stripped) = remaining.strip_prefix(part) else { + return false; + }; + remaining = stripped; + } else { + let Some(index) = remaining.find(part) else { + return false; + }; + remaining = &remaining[index + part.len()..]; + } + first = false; + } + pattern.ends_with('*') || remaining.is_empty() +} + +#[cfg(any(test, target_os = "windows"))] +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedProxyListDecision { + Direct, + Proxy(String), + UnsupportedScheme, + Unavailable, +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision { + let mut saw_unsupported = false; + + { + let mut process_token = |token: &str| { + let decision = parse_proxy_token(token, target_scheme); + match decision { + ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct), + ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)), + ParsedProxyListDecision::UnsupportedScheme => { + saw_unsupported = true; + None + } + ParsedProxyListDecision::Unavailable => None, + } + }; + + for segment in input + .split(';') + .map(str::trim) + .filter(|segment| !segment.is_empty()) + { + let mut parts = segment.split_whitespace(); + let directive = parts.next(); + let hostport = parts.next(); + let extra = parts.next(); + let is_proxy_directive = matches!( + directive.map(str::to_ascii_lowercase).as_deref(), + Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5") + ) && hostport.is_some() + && extra.is_none(); + + if is_proxy_directive { + if let Some(decision) = process_token(segment) { + return decision; + } + } else { + for token in segment.split_whitespace() { + if let Some(decision) = process_token(token) { + return decision; + } + } + } + } + } + + if saw_unsupported { + ParsedProxyListDecision::UnsupportedScheme + } else { + ParsedProxyListDecision::Unavailable + } +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision { + if token.eq_ignore_ascii_case("DIRECT") { + return ParsedProxyListDecision::Direct; + } + + if let Some(decision) = parse_proxy_key_token(token, target_scheme) { + return decision; + } + if token.contains('=') { + return ParsedProxyListDecision::Unavailable; + } + + let mut parts = token.split_whitespace(); + let directive = parts.next(); + let hostport = parts.next(); + if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) { + return match directive.to_ascii_lowercase().as_str() { + "proxy" | "http" => proxy_url_from_hostport("http", hostport), + "https" => proxy_url_from_hostport("https", hostport), + "socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme, + _ => ParsedProxyListDecision::Unavailable, + }; + } + + proxy_url_from_hostport("http", token) +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option { + let (key, value) = token.split_once('=')?; + if key.trim().eq_ignore_ascii_case(target_scheme) { + Some(proxy_url_from_hostport("http", value.trim())) + } else { + Some(ParsedProxyListDecision::Unavailable) + } +} + +#[cfg(any(test, target_os = "windows"))] +fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision { + if hostport.is_empty() { + return ParsedProxyListDecision::Unavailable; + } + if hostport.contains("://") { + return ParsedProxyListDecision::Proxy(hostport.to_string()); + } + ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}")) +} + +trait EnvSource { + fn var(&self, key: &str) -> Option; +} + +struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok() + } +} + +fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option { + let lower = upper.to_ascii_lowercase(); + env.var(upper) + .or_else(|| env.var(&lower)) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +#[path = "outbound_proxy_tests.rs"] +mod tests; diff --git a/codex-rs/codex-client/src/outbound_proxy/macos.rs b/codex-rs/codex-client/src/outbound_proxy/macos.rs new file mode 100644 index 0000000000..3913ecc40b --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/macos.rs @@ -0,0 +1,382 @@ +use std::ffi::c_void; +use std::ptr; +use std::time::Duration; +use std::time::Instant; + +use super::RequestOrigin; +use super::RouteFailureClass; +use super::SystemProxyDecision; +use system_configuration::core_foundation::array::CFArray; +use system_configuration::core_foundation::array::CFArrayRef; +use system_configuration::core_foundation::base::CFEqual; +use system_configuration::core_foundation::base::CFGetTypeID; +use system_configuration::core_foundation::base::CFIndex; +use system_configuration::core_foundation::base::CFType; +use system_configuration::core_foundation::base::CFTypeRef; +use system_configuration::core_foundation::base::TCFType; +use system_configuration::core_foundation::base::kCFAllocatorDefault; +use system_configuration::core_foundation::dictionary::CFDictionary; +use system_configuration::core_foundation::dictionary::CFDictionaryRef; +use system_configuration::core_foundation::error::CFErrorRef; +use system_configuration::core_foundation::number::CFNumber; +use system_configuration::core_foundation::runloop::CFRunLoop; +use system_configuration::core_foundation::runloop::CFRunLoopSource; +use system_configuration::core_foundation::runloop::CFRunLoopSourceInvalidate; +use system_configuration::core_foundation::runloop::CFRunLoopSourceRef; +use system_configuration::core_foundation::runloop::kCFRunLoopDefaultMode; +use system_configuration::core_foundation::string::CFString; +use system_configuration::core_foundation::string::CFStringRef; +use system_configuration::core_foundation::url::CFURL; +use system_configuration::core_foundation::url::CFURLCreateWithString; +use system_configuration::core_foundation::url::CFURLGetTypeID; +use system_configuration::core_foundation::url::CFURLRef; +use system_configuration::dynamic_store::SCDynamicStoreBuilder; + +const PAC_EXECUTION_TIMEOUT: Duration = Duration::from_secs(5); + +type ProxyDictionary = CFDictionary; +type ProxyArray = CFArray; + +#[repr(C)] +struct CFStreamClientContext { + version: CFIndex, + info: *mut c_void, + retain: Option *mut c_void>, + release: Option, + copy_description: Option CFStringRef>, +} + +type CFProxyAutoConfigurationResultCallback = + unsafe extern "C" fn(*mut c_void, CFArrayRef, CFErrorRef); + +#[link(name = "CFNetwork", kind = "framework")] +unsafe extern "C" { + static kCFProxyTypeKey: CFStringRef; + static kCFProxyHostNameKey: CFStringRef; + static kCFProxyPortNumberKey: CFStringRef; + static kCFProxyAutoConfigurationURLKey: CFStringRef; + static kCFProxyAutoConfigurationJavaScriptKey: CFStringRef; + static kCFProxyTypeNone: CFStringRef; + static kCFProxyTypeHTTP: CFStringRef; + static kCFProxyTypeHTTPS: CFStringRef; + static kCFProxyTypeSOCKS: CFStringRef; + static kCFProxyTypeAutoConfigurationURL: CFStringRef; + static kCFProxyTypeAutoConfigurationJavaScript: CFStringRef; + + fn CFNetworkCopyProxiesForURL(url: CFURLRef, proxy_settings: CFDictionaryRef) -> CFArrayRef; + fn CFNetworkExecuteProxyAutoConfigurationURL( + proxy_auto_config_url: CFURLRef, + target_url: CFURLRef, + callback: CFProxyAutoConfigurationResultCallback, + client_context: *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef; + fn CFNetworkExecuteProxyAutoConfigurationScript( + proxy_auto_config_script: CFStringRef, + target_url: CFURLRef, + callback: CFProxyAutoConfigurationResultCallback, + client_context: *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef; +} + +pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let Some(target_url) = cf_url(request_url) else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::InvalidProxyConfig, + }; + }; + + let Some(settings) = system_proxy_settings() else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + + let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + + proxy_array_decision(&proxies, &target_url, origin) +} + +fn system_proxy_settings() -> Option> { + let store = SCDynamicStoreBuilder::new("Codex").build()?; + store.get_proxies() +} + +fn copy_proxies_for_url( + target_url: &CFURL, + settings: &CFDictionary, +) -> Option { + let proxies = unsafe { + CFNetworkCopyProxiesForURL( + target_url.as_concrete_TypeRef(), + settings.as_concrete_TypeRef(), + ) + }; + if proxies.is_null() { + None + } else { + Some(unsafe { ProxyArray::wrap_under_create_rule(proxies) }) + } +} + +fn proxy_array_decision( + proxies: &ProxyArray, + target_url: &CFURL, + origin: &RequestOrigin, +) -> SystemProxyDecision { + let mut saw_unsupported = false; + let mut saw_unavailable = false; + + // CFNetwork returns candidates in failover order, but the shared resolver currently carries + // only one route. This matches the Windows limitation; cross-platform retry requires request + // replay semantics and is intentionally deferred. + for proxy in proxies { + match proxy_entry_decision(&proxy, target_url, origin) { + ProxyEntryDecision::Direct => return SystemProxyDecision::Direct, + ProxyEntryDecision::Proxy { url } => return SystemProxyDecision::Proxy { url }, + ProxyEntryDecision::UnsupportedScheme => saw_unsupported = true, + ProxyEntryDecision::Unavailable => saw_unavailable = true, + } + } + + if saw_unsupported { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + } + } else if saw_unavailable { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } + } else { + SystemProxyDecision::Direct + } +} + +fn proxy_entry_decision( + proxy: &ProxyDictionary, + target_url: &CFURL, + origin: &RequestOrigin, +) -> ProxyEntryDecision { + let Some(proxy_type) = cf_string_value(proxy, unsafe { kCFProxyTypeKey }) else { + return ProxyEntryDecision::Unavailable; + }; + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone }) { + return ProxyEntryDecision::Direct; + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP }) { + return concrete_proxy_entry(proxy, "http"); + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS }) { + // CFNetwork's HTTPS proxy type is a tunneling proxy for HTTPS destinations; it does not + // preserve an explicit TLS-to-proxy transport. See https://developer.apple.com/documentation/cfnetwork/kcfproxytypehttps. + return concrete_proxy_entry(proxy, "http"); + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeSOCKS }) { + return ProxyEntryDecision::UnsupportedScheme; + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL }) { + let Some(pac_url) = cf_url_value(proxy, unsafe { kCFProxyAutoConfigurationURLKey }) else { + return ProxyEntryDecision::Unavailable; + }; + return pac_decision(execute_pac_url(&pac_url, target_url), target_url, origin); + } + + if cf_string_equals(&proxy_type, unsafe { + kCFProxyTypeAutoConfigurationJavaScript + }) { + let Some(script) = + cf_string_value(proxy, unsafe { kCFProxyAutoConfigurationJavaScriptKey }) + else { + return ProxyEntryDecision::Unavailable; + }; + return pac_decision( + execute_pac(|callback, context| unsafe { + CFNetworkExecuteProxyAutoConfigurationScript( + script.as_concrete_TypeRef(), + target_url.as_concrete_TypeRef(), + callback, + context, + ) + }), + target_url, + origin, + ); + } + + ProxyEntryDecision::Unavailable +} + +fn pac_decision( + result: Result, + target_url: &CFURL, + origin: &RequestOrigin, +) -> ProxyEntryDecision { + let proxies = match result { + Ok(proxies) => proxies, + Err(RouteFailureClass::UnsupportedProxyScheme) => { + return ProxyEntryDecision::UnsupportedScheme; + } + Err(_) => return ProxyEntryDecision::Unavailable, + }; + + match proxy_array_decision(&proxies, target_url, origin) { + SystemProxyDecision::Direct => ProxyEntryDecision::Direct, + SystemProxyDecision::Proxy { url } => ProxyEntryDecision::Proxy { url }, + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + } => ProxyEntryDecision::UnsupportedScheme, + SystemProxyDecision::Unavailable { failure: _ } => ProxyEntryDecision::Unavailable, + } +} + +fn execute_pac_url(pac_url: &CFURL, target_url: &CFURL) -> Result { + execute_pac(|callback, context| unsafe { + CFNetworkExecuteProxyAutoConfigurationURL( + pac_url.as_concrete_TypeRef(), + target_url.as_concrete_TypeRef(), + callback, + context, + ) + }) +} + +fn execute_pac( + create_source: impl FnOnce( + CFProxyAutoConfigurationResultCallback, + *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef, +) -> Result { + let mut state = PacRunLoopState { result: None }; + let mut context = CFStreamClientContext { + version: 0, + info: (&mut state as *mut PacRunLoopState).cast::(), + retain: None, + release: None, + copy_description: None, + }; + + let source = create_source(pac_result_callback, &mut context); + if source.is_null() { + return Err(RouteFailureClass::ProxyResolutionUnavailable); + } + + let source = unsafe { CFRunLoopSource::wrap_under_create_rule(source) }; + let run_loop = CFRunLoop::get_current(); + let mode = unsafe { kCFRunLoopDefaultMode }; + run_loop.add_source(&source, mode); + + let started_at = Instant::now(); + while state.result.is_none() && started_at.elapsed() < PAC_EXECUTION_TIMEOUT { + CFRunLoop::run_in_mode(mode, Duration::from_millis(50), true); + } + + if state.result.is_none() { + unsafe { CFRunLoopSourceInvalidate(source.as_concrete_TypeRef()) }; + } + run_loop.remove_source(&source, mode); + state + .result + .unwrap_or(Err(RouteFailureClass::ConnectTimeout)) +} + +unsafe extern "C" fn pac_result_callback( + client: *mut c_void, + proxies: CFArrayRef, + error: CFErrorRef, +) { + let state = unsafe { &mut *client.cast::() }; + state.result = if !error.is_null() || proxies.is_null() { + Some(Err(RouteFailureClass::ProxyResolutionUnavailable)) + } else { + Some(Ok(unsafe { ProxyArray::wrap_under_get_rule(proxies) })) + }; + CFRunLoop::get_current().stop(); +} + +struct PacRunLoopState { + result: Option>, +} + +fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEntryDecision { + let Some(host) = cf_string_value(proxy, unsafe { kCFProxyHostNameKey }) + .map(|host| host.to_string()) + .filter(|host| !host.is_empty()) + else { + return ProxyEntryDecision::Unavailable; + }; + + let host = bracket_ipv6_host(&host); + let url = match cf_i32_value(proxy, unsafe { kCFProxyPortNumberKey }) { + Some(port) if port > 0 => format!("{proxy_scheme}://{host}:{port}"), + _ => format!("{proxy_scheme}://{host}"), + }; + ProxyEntryDecision::Proxy { url } +} + +fn bracket_ipv6_host(host: &str) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + } +} + +fn cf_string_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy + .find(key) + .and_then(|value| value.downcast::()) +} + +fn cf_i32_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy + .find(key) + .and_then(|value| value.downcast::()) + .and_then(|value| value.to_i32()) +} + +fn cf_url_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy.find(key).and_then(|value| { + if unsafe { CFGetTypeID(value.as_CFTypeRef()) == CFURLGetTypeID() } { + Some(unsafe { CFURL::wrap_under_get_rule(value.as_CFTypeRef() as CFURLRef) }) + } else { + value + .downcast::() + .and_then(|value| cf_url(value.to_string().as_str())) + } + }) +} + +fn cf_string_equals(value: &CFString, expected: CFStringRef) -> bool { + unsafe { CFEqual(value.as_CFTypeRef(), expected as CFTypeRef) != 0 } +} + +fn cf_url(value: &str) -> Option { + let value = CFString::new(value); + let url = unsafe { + CFURLCreateWithString( + kCFAllocatorDefault, + value.as_concrete_TypeRef(), + ptr::null(), + ) + }; + if url.is_null() { + None + } else { + Some(unsafe { CFURL::wrap_under_create_rule(url) }) + } +} + +enum ProxyEntryDecision { + Direct, + Proxy { url: String }, + UnsupportedScheme, + Unavailable, +} diff --git a/codex-rs/codex-client/src/outbound_proxy/windows.rs b/codex-rs/codex-client/src/outbound_proxy/windows.rs new file mode 100644 index 0000000000..9e64196705 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/windows.rs @@ -0,0 +1,362 @@ +use std::ffi::c_void; +use std::ptr; + +use super::ParsedProxyListDecision; +use super::RequestOrigin; +use super::RouteFailureClass; +use super::SystemProxyDecision; +use super::no_proxy_matches_origin; +use super::parse_proxy_list; +use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; +use windows_sys::Win32::Foundation::FALSE; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::GlobalFree; +use windows_sys::Win32::Foundation::TRUE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_AUTODETECTION_FAILED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CANNOT_CONNECT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CONNECTION_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_INVALID_URL; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_LOGIN_FAILURE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_NAME_NOT_RESOLVED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_CN_INVALID; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_DATE_INVALID; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REV_FAILED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REVOKED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CHANNEL_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_FAILURE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CA; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CERT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_TIMEOUT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNRECOGNIZED_SCHEME; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NAMED_PROXY; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NO_PROXY; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DHCP; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DNS_A; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_AUTO_DETECT; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_CONFIG_URL; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_OPTIONS; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_CURRENT_USER_IE_PROXY_CONFIG; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_PROXY_INFO; +use windows_sys::Win32::Networking::WinHttp::WinHttpCloseHandle; +use windows_sys::Win32::Networking::WinHttp::WinHttpGetIEProxyConfigForCurrentUser; +use windows_sys::Win32::Networking::WinHttp::WinHttpGetProxyForUrl; +use windows_sys::Win32::Networking::WinHttp::WinHttpOpen; +use windows_sys::core::PWSTR; + +pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let ie_config = match current_user_ie_proxy_config() { + Ok(config) => config, + Err(failure) => { + return SystemProxyDecision::Unavailable { failure }; + } + }; + + if let Some(pac_url) = ie_config.auto_config_url.as_deref() { + let decision = resolve_with_pac_url(request_url, origin, pac_url); + if !matches!(decision, SystemProxyDecision::Unavailable { .. }) { + return decision; + } + } + + if ie_config.auto_detect { + let decision = resolve_with_auto_detect(request_url, origin); + if !matches!(decision, SystemProxyDecision::Unavailable { .. }) { + return decision; + } + } + + if let Some(proxy) = ie_config.static_proxy.as_deref() { + if ie_config + .proxy_bypass + .as_deref() + .is_some_and(|bypass| proxy_bypass_matches_origin(bypass, origin)) + { + return SystemProxyDecision::Direct; + } + return proxy_list_decision(proxy, origin); + } + + if ie_config.auto_config_url.is_some() || ie_config.auto_detect { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } + } else { + SystemProxyDecision::Direct + } +} + +fn resolve_with_pac_url( + request_url: &str, + origin: &RequestOrigin, + pac_url: &str, +) -> SystemProxyDecision { + let pac_url = wide_null(pac_url); + let options = WINHTTP_AUTOPROXY_OPTIONS { + dwFlags: WINHTTP_AUTOPROXY_CONFIG_URL, + dwAutoDetectFlags: 0, + lpszAutoConfigUrl: pac_url.as_ptr(), + lpvReserved: ptr::null_mut(), + dwReserved: 0, + fAutoLogonIfChallenged: TRUE, + }; + resolve_with_winhttp_options(request_url, origin, options) +} + +fn resolve_with_auto_detect(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let options = WINHTTP_AUTOPROXY_OPTIONS { + dwFlags: WINHTTP_AUTOPROXY_AUTO_DETECT, + dwAutoDetectFlags: WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A, + lpszAutoConfigUrl: ptr::null(), + lpvReserved: ptr::null_mut(), + dwReserved: 0, + fAutoLogonIfChallenged: FALSE, + }; + resolve_with_winhttp_options(request_url, origin, options) +} + +fn resolve_with_winhttp_options( + request_url: &str, + origin: &RequestOrigin, + mut options: WINHTTP_AUTOPROXY_OPTIONS, +) -> SystemProxyDecision { + let Some(session) = WinHttpSession::open() else { + return SystemProxyDecision::Unavailable { + failure: classify_winhttp_error(last_error()), + }; + }; + + let request_url = wide_null(request_url); + let mut proxy_info = WINHTTP_PROXY_INFO { + dwAccessType: WINHTTP_ACCESS_TYPE_NO_PROXY, + lpszProxy: ptr::null_mut(), + lpszProxyBypass: ptr::null_mut(), + }; + let ok = unsafe { + WinHttpGetProxyForUrl( + session.0, + request_url.as_ptr(), + &mut options, + &mut proxy_info, + ) + }; + if ok == FALSE { + return SystemProxyDecision::Unavailable { + failure: classify_winhttp_error(last_error()), + }; + } + + let proxy_info = ProxyInfo::from_raw(proxy_info); + if proxy_info.access_type == WINHTTP_ACCESS_TYPE_NO_PROXY { + return SystemProxyDecision::Direct; + } + if proxy_info.access_type != WINHTTP_ACCESS_TYPE_NAMED_PROXY { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + } + let Some(proxy) = proxy_info.proxy.as_deref() else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + proxy_list_decision(proxy, origin) +} + +fn proxy_list_decision(proxy_list: &str, origin: &RequestOrigin) -> SystemProxyDecision { + match parse_proxy_list(proxy_list, &origin.scheme) { + ParsedProxyListDecision::Direct => SystemProxyDecision::Direct, + ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { url }, + ParsedProxyListDecision::UnsupportedScheme => SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + }, + ParsedProxyListDecision::Unavailable => SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }, + } +} + +fn current_user_ie_proxy_config() -> Result { + let mut raw = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG { + fAutoDetect: FALSE, + lpszAutoConfigUrl: ptr::null_mut(), + lpszProxy: ptr::null_mut(), + lpszProxyBypass: ptr::null_mut(), + }; + let ok = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut raw) }; + if ok == FALSE { + let error = last_error(); + if error == ERROR_FILE_NOT_FOUND { + // Match WinHTTP's fallback by attempting WPAD when no IE proxy settings exist. + return Ok(IeProxyConfig { + auto_detect: true, + ..Default::default() + }); + } + return Err(classify_winhttp_error(error)); + } + + let auto_config_url = GlobalWideString::from_raw(raw.lpszAutoConfigUrl).into_string(); + let static_proxy = GlobalWideString::from_raw(raw.lpszProxy).into_string(); + let proxy_bypass = GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(); + + Ok(IeProxyConfig { + auto_detect: raw.fAutoDetect != FALSE, + auto_config_url, + static_proxy, + proxy_bypass, + }) +} + +#[derive(Debug, Default)] +struct IeProxyConfig { + auto_detect: bool, + auto_config_url: Option, + static_proxy: Option, + proxy_bypass: Option, +} + +struct ProxyInfo { + access_type: u32, + proxy: Option, + _proxy_bypass: Option, +} + +impl ProxyInfo { + fn from_raw(raw: WINHTTP_PROXY_INFO) -> Self { + Self { + access_type: raw.dwAccessType, + proxy: GlobalWideString::from_raw(raw.lpszProxy).into_string(), + _proxy_bypass: GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(), + } + } +} + +struct GlobalWideString(PWSTR); + +impl GlobalWideString { + fn from_raw(ptr: PWSTR) -> Self { + Self(ptr) + } + + fn into_string(self) -> Option { + if self.0.is_null() { + return None; + } + let string = unsafe { wide_ptr_to_string(self.0) }; + if string.is_empty() { + None + } else { + Some(string) + } + } +} + +impl Drop for GlobalWideString { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + GlobalFree(self.0.cast::()); + } + } + } +} + +struct WinHttpSession(*mut c_void); + +impl WinHttpSession { + fn open() -> Option { + let agent = wide_null("Codex"); + let handle = unsafe { + WinHttpOpen( + agent.as_ptr(), + WINHTTP_ACCESS_TYPE_NO_PROXY, + ptr::null(), + ptr::null(), + 0, + ) + }; + if handle.is_null() { + None + } else { + Some(Self(handle)) + } + } +} + +impl Drop for WinHttpSession { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + WinHttpCloseHandle(self.0); + } + } + } +} + +fn proxy_bypass_matches_origin(proxy_bypass: &str, origin: &RequestOrigin) -> bool { + proxy_bypass + .split(|ch: char| ch == ';' || ch == ',' || ch.is_whitespace()) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .any(|entry| { + if entry.eq_ignore_ascii_case("") { + !origin.host.contains('.') + } else { + no_proxy_matches_origin(entry, origin) + } + }) +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; + +fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn wide_ptr_to_string(ptr: PWSTR) -> String { + let mut len = 0; + while unsafe { *ptr.add(len) } != 0 { + len += 1; + } + let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; + String::from_utf16_lossy(slice) +} + +fn last_error() -> u32 { + unsafe { GetLastError() } +} + +fn classify_winhttp_error(code: u32) -> RouteFailureClass { + match code { + ERROR_WINHTTP_TIMEOUT => RouteFailureClass::ConnectTimeout, + ERROR_WINHTTP_LOGIN_FAILURE => RouteFailureClass::ProxyAuthenticationRequired, + ERROR_WINHTTP_AUTODETECTION_FAILED + | ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT + | ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR + | ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT + | ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE => RouteFailureClass::ProxyResolutionUnavailable, + ERROR_WINHTTP_SECURE_CERT_CN_INVALID + | ERROR_WINHTTP_SECURE_CERT_DATE_INVALID + | ERROR_WINHTTP_SECURE_CERT_REVOKED + | ERROR_WINHTTP_SECURE_CERT_REV_FAILED + | ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE + | ERROR_WINHTTP_SECURE_CHANNEL_ERROR + | ERROR_WINHTTP_SECURE_FAILURE + | ERROR_WINHTTP_SECURE_INVALID_CA + | ERROR_WINHTTP_SECURE_INVALID_CERT => RouteFailureClass::TlsError, + ERROR_WINHTTP_INVALID_URL | ERROR_WINHTTP_UNRECOGNIZED_SCHEME => { + RouteFailureClass::InvalidProxyConfig + } + ERROR_WINHTTP_CANNOT_CONNECT + | ERROR_WINHTTP_CONNECTION_ERROR + | ERROR_WINHTTP_NAME_NOT_RESOLVED => RouteFailureClass::ResolverError, + _ => RouteFailureClass::ResolverError, + } +} diff --git a/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs b/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs new file mode 100644 index 0000000000..3772c057db --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn proxy_bypass_matches_whitespace_separated_winhttp_entries() { + let local_origin = RequestOrigin { + scheme: "https".to_string(), + host: "intranet".to_string(), + port: 443, + }; + assert!(proxy_bypass_matches_origin(" *.corp", &local_origin)); + + let corp_origin = RequestOrigin { + scheme: "https".to_string(), + host: "service.corp".to_string(), + port: 443, + }; + assert!(proxy_bypass_matches_origin(" *.corp", &corp_origin)); +} diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs new file mode 100644 index 0000000000..46868e8f73 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -0,0 +1,212 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io::Read; +use std::io::Write; + +struct MapEnv { + values: HashMap, +} + +impl EnvSource for MapEnv { + fn var(&self, key: &str) -> Option { + self.values.get(key).cloned() + } +} + +#[test] +fn proxy_env_value_matches_reqwest_casing_precedence() { + let env = MapEnv { + values: HashMap::from([ + ("HTTPS_PROXY".to_string(), "upper".to_string()), + ("https_proxy".to_string(), "lower".to_string()), + ("http_proxy".to_string(), "lower-only".to_string()), + ("ALL_PROXY".to_string(), String::new()), + ("all_proxy".to_string(), "masked".to_string()), + ]), + }; + + assert_eq!( + proxy_env_value(&env, "HTTPS_PROXY"), + Some("upper".to_string()) + ); + assert_eq!( + proxy_env_value(&env, "HTTP_PROXY"), + Some("lower-only".to_string()) + ); + assert_eq!(proxy_env_value(&env, "ALL_PROXY"), None); +} + +#[test] +fn environment_fallback_reads_injected_proxy_environment() { + let env = MapEnv { + values: HashMap::from([("HTTPS_PROXY".to_string(), "://invalid".to_string())]), + }; + let origin = RequestOrigin::parse("https://auth.openai.com/oauth/token").expect("valid URL"); + let result = configure_env_proxy_handling( + &env, + reqwest::Client::builder(), + Some(&origin), + ClientRouteClass::Auth, + ); + + assert!(matches!( + result, + Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { + route_class: ClientRouteClass::Auth, + }) + )); +} + +#[tokio::test] +async fn enabled_environment_proxy_routes_request_through_proxy() { + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).expect("local proxy listener should bind"); + let proxy_addr = listener + .local_addr() + .expect("local proxy listener should have an address"); + let proxy_thread = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("proxy should accept a request"); + let mut buffer = [0_u8; 4096]; + let size = stream.read(&mut buffer).expect("proxy should read request"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .expect("proxy should write response"); + String::from_utf8_lossy(&buffer[..size]).into_owned() + }); + let env = MapEnv { + values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]), + }; + let request_url = "http://enabled-proxy.test/proxy-check"; + let config = OutboundProxyConfig::respect_system_proxy(); + let builder = configure_proxy_for_route( + &env, + reqwest::Client::builder().timeout(Duration::from_secs(2)), + request_url, + ClientRouteClass::Auth, + Some(&config), + |_, _| SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }, + ) + .expect("enabled proxy route should configure"); + + let response = builder + .build() + .expect("proxy client should build") + .get(request_url) + .send() + .await + .expect("request should use local proxy"); + let proxy_request = proxy_thread.join().expect("proxy thread should finish"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + proxy_request.lines().next(), + Some("GET http://enabled-proxy.test/proxy-check HTTP/1.1") + ); +} + +#[test] +fn parses_pac_proxy_tokens() { + assert_eq!( + parse_proxy_list("PROXY proxy.internal:8080; DIRECT", "https"), + ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string()) + ); + assert_eq!( + parse_proxy_list("HTTPS proxy.internal:8443", "https"), + ParsedProxyListDecision::Proxy("https://proxy.internal:8443".to_string()) + ); +} + +#[test] +fn unavailable_system_proxy_decision_is_cached() { + let request_url = "https://unavailable-cache.test/oauth/token"; + let decision = SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + + cache_system_proxy_decision(request_url, decision.clone()); + + assert_eq!(cached_system_proxy_decision(request_url), Some(decision)); +} + +#[test] +fn system_proxy_cache_is_bounded() { + let mut cache = HashMap::new(); + let now = Instant::now(); + + for index in 0..=SYSTEM_PROXY_CACHE_MAX_ENTRIES { + insert_system_proxy_cache_entry( + &mut cache, + &format!("https://bounded-cache.test/{index}"), + SystemProxyDecision::Direct, + now, + ); + } + + assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES); +} + +#[test] +fn parses_static_winhttp_proxy_entries_for_target_scheme() { + assert_eq!( + parse_proxy_list("http=web-proxy:8080;https=secure-proxy:8443", "https"), + ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string()) + ); + assert_eq!( + parse_proxy_list("http=web-proxy:8080 https=secure-proxy:8443", "https"), + ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string()) + ); + assert_eq!( + parse_proxy_list("http=web-proxy:8080", "https"), + ParsedProxyListDecision::Unavailable + ); + assert_eq!( + parse_proxy_list("proxy.internal:8080", "https"), + ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string()) + ); +} + +#[test] +fn reports_direct_and_unsupported_proxy_tokens() { + assert_eq!( + parse_proxy_list("DIRECT; PROXY proxy.internal:8080", "https"), + ParsedProxyListDecision::Direct + ); + assert_eq!( + parse_proxy_list("DIRECT", "https"), + ParsedProxyListDecision::Direct + ); + assert_eq!( + parse_proxy_list("SOCKS proxy.internal:1080", "https"), + ParsedProxyListDecision::UnsupportedScheme + ); +} + +#[test] +fn no_proxy_matches_exact_suffix_wildcard_and_port() { + let origin = RequestOrigin { + scheme: "https".to_string(), + host: "auth.openai.com".to_string(), + port: 443, + }; + assert!(no_proxy_matches_origin("auth.openai.com", &origin)); + assert!(!no_proxy_matches_origin("openai.com", &origin)); + assert!(no_proxy_matches_origin(".openai.com", &origin)); + assert!(no_proxy_matches_origin("*.openai.com", &origin)); + assert!(no_proxy_matches_origin("auth.openai.com:443", &origin)); + assert!(!no_proxy_matches_origin("auth.openai.com:8443", &origin)); +} + +#[test] +fn system_proxy_cache_key_preserves_url_specific_pac_decisions() { + let request_url = "https://auth.openai.com/oauth/token?access_token=secret"; + let cache_key = system_proxy_cache_key(request_url); + + assert_ne!( + cache_key, + system_proxy_cache_key("https://auth.openai.com/oauth/revoke") + ); + #[cfg(any(target_os = "windows", target_os = "macos"))] + assert!(!cache_key.contains(request_url)); +} diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index b22a45f0d1..437e750de8 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -8,7 +8,6 @@ use std::path::PathBuf; use std::time::Instant; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; @@ -66,15 +65,7 @@ pub(crate) enum CachedCodexAppsToolsLoad { Invalid, } -pub(crate) fn normalize_codex_apps_tool_title( - server_name: &str, - connector_name: Option<&str>, - value: &str, -) -> String { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return value.to_string(); - } - +pub(crate) fn normalize_codex_apps_tool_title(connector_name: Option<&str>, value: &str) -> String { let Some(connector_name) = connector_name .map(str::trim) .filter(|name| !name.is_empty()) @@ -93,15 +84,10 @@ pub(crate) fn normalize_codex_apps_tool_title( } pub(crate) fn normalize_codex_apps_callable_name( - server_name: &str, tool_name: &str, connector_id: Option<&str>, connector_name: Option<&str>, ) -> String { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return tool_name.to_string(); - } - let tool_name = sanitize_name(tool_name); if let Some(connector_name) = connector_name @@ -131,25 +117,18 @@ pub(crate) fn normalize_codex_apps_callable_namespace( server_name: &str, connector_name: Option<&str>, ) -> String { - if server_name == CODEX_APPS_MCP_SERVER_NAME - && let Some(connector_name) = connector_name - { + if let Some(connector_name) = connector_name { format!("{}__{}", server_name, sanitize_name(connector_name)) } else { server_name.to_string() } } -pub(crate) fn write_cached_codex_apps_tools_if_needed( - server_name: &str, +pub(crate) fn write_codex_apps_tools_cache( cache_context: Option<&CodexAppsToolsCacheContext>, server_info: &McpServerInfo, tools: &[ToolInfo], ) { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return; - } - if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); write_cached_codex_apps_tools(cache_context, tools); @@ -165,13 +144,8 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( } pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( - server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, ) -> Option> { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return None; - } - let cache_context = cache_context?; match load_cached_codex_apps_tools(cache_context) { @@ -181,13 +155,8 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( } pub(crate) fn load_startup_cached_codex_apps_server_info( - server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, ) -> Option { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return None; - } - load_cached_codex_apps_server_info(cache_context?) } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index ea55d21379..53e9efc6d7 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -7,6 +7,7 @@ //! `codex-core`. use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -16,7 +17,7 @@ use std::time::Instant; use crate::McpAuthStatusEntry; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; -use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::codex_apps::write_codex_apps_tools_cache; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -40,6 +41,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use async_channel::Sender; +use codex_api::SharedAuthProvider; use codex_config::Constrained; use codex_config::McpServerTransportConfig; use codex_config::types::AuthKeyringBackendKind; @@ -109,7 +111,6 @@ pub struct McpConnectionManager { server_metadata: HashMap, required_servers: Vec, tool_plugin_provenance: Arc, - host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, @@ -130,7 +131,6 @@ impl McpConnectionManager { runtime_context: McpRuntimeContext, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, - host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, supports_openai_form_elicitation: bool, @@ -173,29 +173,16 @@ impl McpConnectionManager { }, ) .await; - let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { - Some(CodexAppsToolsCacheContext { - codex_home: codex_home.clone(), - user_key: codex_apps_tools_cache_key.clone(), - }) - } else { - None - }; - let uses_env_bearer_token = - server - .configured_config() - .is_some_and(|config| match &config.transport { - McpServerTransportConfig::StreamableHttp { - bearer_token_env_var, - .. - } => bearer_token_env_var.is_some(), - McpServerTransportConfig::Stdio { .. } => false, - }); - let runtime_auth_provider = - if server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token { - codex_apps_auth_provider.clone() + let (codex_apps_tools_cache_context, runtime_auth_provider) = + if server_name == CODEX_APPS_MCP_SERVER_NAME { + codex_apps_cache_context_and_auth_provider( + &server, + &codex_home, + &codex_apps_tools_cache_key, + codex_apps_auth_provider.clone(), + ) } else { - None + regular_mcp_cache_context_and_auth_provider() }; let async_managed_client = AsyncManagedClient::new( server_name.clone(), @@ -252,7 +239,6 @@ impl McpConnectionManager { server_metadata, required_servers, tool_plugin_provenance, - host_owned_codex_apps_enabled, prefix_mcp_tool_names, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: startup_cancellation_token.clone(), @@ -338,7 +324,6 @@ impl McpConnectionManager { server_metadata: HashMap::new(), required_servers: Vec::new(), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), - host_owned_codex_apps_enabled: false, prefix_mcp_tool_names, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), @@ -406,7 +391,7 @@ impl McpConnectionManager { } pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { - self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME + server_name == CODEX_APPS_MCP_SERVER_NAME && self.server_metadata.contains_key(server_name) } pub fn set_approval_policy(&self, approval_policy: &Constrained) { @@ -510,6 +495,7 @@ impl McpConnectionManager { let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached( CODEX_APPS_MCP_SERVER_NAME, + /*is_codex_apps_mcp_server*/ true, &managed_client.client, managed_client.tool_timeout, managed_client.server_instructions.as_deref(), @@ -524,8 +510,7 @@ impl McpConnectionManager { &[], ); - write_cached_codex_apps_tools_if_needed( - CODEX_APPS_MCP_SERVER_NAME, + write_codex_apps_tools_cache( managed_client.codex_apps_tools_cache_context.as_ref(), &managed_client.server_info, &tools, @@ -860,6 +845,50 @@ impl Drop for McpConnectionManager { } } +/// Creates the host-owned state used only by the Codex Apps server. +/// +/// The tools cache is scoped to the authenticated user. Runtime authentication is supplied only +/// when the server is not already configured to read a bearer token from the environment. +fn codex_apps_cache_context_and_auth_provider( + server: &EffectiveMcpServer, + codex_home: &Path, + codex_apps_tools_cache_key: &CodexAppsToolsCacheKey, + codex_apps_auth_provider: Option, +) -> ( + Option, + Option, +) { + let uses_env_bearer_token = + server + .configured_config() + .is_some_and(|config| match &config.transport { + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var, + .. + } => bearer_token_env_var.is_some(), + McpServerTransportConfig::Stdio { .. } => false, + }); + ( + Some(CodexAppsToolsCacheContext { + codex_home: codex_home.to_path_buf(), + user_key: codex_apps_tools_cache_key.clone(), + }), + if uses_env_bearer_token { + None + } else { + codex_apps_auth_provider + }, + ) +} + +/// Keeps regular MCP servers isolated from the host-owned Codex Apps cache and auth provider. +fn regular_mcp_cache_context_and_auth_provider() -> ( + Option, + Option, +) { + (None, None) +} + async fn emit_update( submit_id: &str, tx_event: &Sender, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 52194b07f1..887a9b3d46 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -5,7 +5,7 @@ use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::read_cached_codex_apps_tools; use crate::codex_apps::write_cached_codex_apps_tools; -use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::codex_apps::write_codex_apps_tools_cache; use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -704,22 +704,11 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { "calendar_search", )]; let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools_if_needed( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - &server_info, - &cached_tools, - ); + write_codex_apps_tools_cache(Some(&cache_context), &server_info, &cached_tools); - let startup_tools = load_startup_cached_codex_apps_tools_snapshot( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ) - .expect("expected startup snapshot to load from cache"); - let cached_server_info = load_startup_cached_codex_apps_server_info( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ); + let startup_tools = load_startup_cached_codex_apps_tools_snapshot(Some(&cache_context)) + .expect("expected startup snapshot to load from cache"); + let cached_server_info = load_startup_cached_codex_apps_server_info(Some(&cache_context)); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); @@ -746,15 +735,9 @@ fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { .expect("serialize"); std::fs::write(cache_path, bytes).expect("write"); - let startup_tools = load_startup_cached_codex_apps_tools_snapshot( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ) - .expect("legacy startup snapshot should remain available"); - let cached_server_info = load_startup_cached_codex_apps_server_info( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ); + let startup_tools = load_startup_cached_codex_apps_tools_snapshot(Some(&cache_context)) + .expect("legacy startup snapshot should remain available"); + let cached_server_info = load_startup_cached_codex_apps_server_info(Some(&cache_context)); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].callable_name, "calendar_search"); @@ -770,8 +753,7 @@ fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { Some("user-one"), ); let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools_if_needed( - CODEX_APPS_MCP_SERVER_NAME, + write_codex_apps_tools_cache( Some(&cache_context), &server_info, &[create_test_tool( @@ -792,19 +774,10 @@ fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { std::fs::write(cache_path, bytes).expect("write legacy tools cache"); assert_eq!( - load_startup_cached_codex_apps_server_info( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ), + load_startup_cached_codex_apps_server_info(Some(&cache_context)), Some(server_info) ); - assert!( - load_startup_cached_codex_apps_tools_snapshot( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ) - .is_none() - ); + assert!(load_startup_cached_codex_apps_tools_snapshot(Some(&cache_context)).is_none()); } #[tokio::test] @@ -827,6 +800,7 @@ async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -864,6 +838,7 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(Vec::new()), cached_server_info: Some(server_info.clone()), startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -901,6 +876,7 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: false, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -944,6 +920,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: false, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -986,6 +963,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_ CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: None, cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1022,6 +1000,7 @@ async fn shutdown_cancels_pending_tool_listing() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: None, cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1057,6 +1036,7 @@ async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(Vec::new()), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1097,6 +1077,7 @@ async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: failed_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: Some(server_info.clone()), startup_complete, @@ -1155,6 +1136,7 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { server_name.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: false, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1195,6 +1177,41 @@ fn server_metadata_preserves_tool_approval_policy() { ); } +#[test] +fn host_owned_codex_apps_requires_server_metadata() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + + assert!(!manager.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME)); +} + +#[test] +fn host_owned_codex_apps_matches_reserved_name_with_server_metadata() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + let server = EffectiveMcpServer::configured(crate::codex_apps_mcp_server_config( + "https://chatgpt.com", + /*apps_mcp_product_sku*/ None, + )); + manager.server_metadata.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + McpServerMetadata::from(&server), + ); + + assert!(manager.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME)); + assert!(!manager.is_host_owned_codex_apps_server("docs")); +} + #[tokio::test] async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); @@ -1276,7 +1293,6 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { chatgpt_user_id: None, is_workspace_account: false, }, - /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index ec67f07a82..78d55fb789 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::OAuthProviderError; use codex_rmcp_client::determine_streamable_http_auth_status; use codex_rmcp_client::discover_streamable_http_oauth; +use futures::FutureExt; use futures::future::join_all; use tracing::warn; @@ -217,6 +218,7 @@ async fn compute_auth_status( store_mode, keyring_backend_kind, ) + .boxed() .await } } diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 4076880c1d..4a765e6a62 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -277,7 +277,6 @@ pub async fn read_mcp_resource( uri: &str, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); - let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), @@ -302,7 +301,6 @@ pub async fn read_mcp_resource( runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), - host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), /*supports_openai_form_elicitation*/ false, @@ -337,7 +335,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); - let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { @@ -377,7 +374,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), - host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/codex-mcp/src/plugin_config_tests.rs b/codex-rs/codex-mcp/src/plugin_config_tests.rs index 0ba8f42f35..44f7e8208c 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -7,6 +7,7 @@ use codex_config::McpServerConfig; use codex_config::McpServerEnvVar; use codex_config::McpServerOAuthConfig; use codex_config::McpServerTransportConfig; +use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::collections::HashMap; @@ -31,7 +32,7 @@ fn stdio_server( args: Vec::new(), env: None, env_vars, - cwd: Some(cwd.to_path_buf()), + cwd: Some(LegacyAppPathString::from_path(cwd)), }, environment_id: environment_id.to_string(), enabled: true, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index d6c2590ba4..f9cc6a5e81 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -25,7 +25,7 @@ use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; use crate::codex_apps::normalize_codex_apps_tool_title; -use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::codex_apps::write_codex_apps_tools_cache; use crate::elicitation::ElicitationRequestManager; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; @@ -55,6 +55,7 @@ use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; use codex_rmcp_client::RmcpClient; use codex_rmcp_client::StdioServerLauncher; +use codex_rmcp_client::ToolWithConnectorId; use futures::future::BoxFuture; use futures::future::FutureExt; use futures::future::Shared; @@ -129,6 +130,7 @@ impl ManagedClient { #[derive(Clone)] pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, + pub(crate) is_codex_apps_mcp_server: bool, pub(crate) cached_tool_info_snapshot: Option>, pub(crate) cached_server_info: Option, pub(crate) startup_complete: Arc, @@ -155,20 +157,23 @@ impl AsyncManagedClient { client_elicitation_capability: ElicitationCapability, supports_openai_form_elicitation: bool, ) -> Self { + let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; let tool_filter = server .configured_config() .map(ToolFilter::from_config) .unwrap_or_default(); - let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot( - &server_name, - codex_apps_tools_cache_context.as_ref(), - ); + let (cached_tool_info_snapshot, cached_server_info) = if is_codex_apps_mcp_server { + ( + load_startup_cached_codex_apps_tools_snapshot( + codex_apps_tools_cache_context.as_ref(), + ), + load_startup_cached_codex_apps_server_info(codex_apps_tools_cache_context.as_ref()), + ) + } else { + (None, None) + }; let cached_tool_info_snapshot = cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter)); - let cached_server_info = load_startup_cached_codex_apps_server_info( - &server_name, - codex_apps_tools_cache_context.as_ref(), - ); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); @@ -194,6 +199,7 @@ impl AsyncManagedClient { server_name, client, StartServerTaskParams { + is_codex_apps_mcp_server, startup_timeout: server .configured_config() .and_then(|config| config.startup_timeout_sec) @@ -232,6 +238,7 @@ impl AsyncManagedClient { Self { client, + is_codex_apps_mcp_server, cached_tool_info_snapshot, cached_server_info, startup_complete, @@ -255,65 +262,7 @@ impl AsyncManagedClient { } } - fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { - if !self.startup_complete.load(Ordering::Acquire) { - return self.cached_tool_info_snapshot.clone(); - } - None - } - pub(crate) async fn listed_tools(&self) -> Option> { - let annotate_tools = |tools: Vec| { - let mut tools = tools; - for tool in &mut tools { - if tool.server_name == CODEX_APPS_MCP_SERVER_NAME { - tool.tool = tool_with_model_visible_input_schema(&tool.tool); - } - - let plugin_names = match tool.connector_id.as_deref() { - Some(connector_id) => self - .tool_plugin_provenance - .plugin_display_names_for_connector_id(connector_id), - None => self - .tool_plugin_provenance - .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), - }; - tool.plugin_display_names = plugin_names.to_vec(); - - if plugin_names.is_empty() { - continue; - } - - let plugin_source_note = if plugin_names.len() == 1 { - format!("This tool is part of plugin `{}`.", plugin_names[0]) - } else { - format!( - "This tool is part of plugins {}.", - plugin_names - .iter() - .map(|plugin_name| format!("`{plugin_name}`")) - .collect::>() - .join(", ") - ) - }; - let description = tool - .tool - .description - .as_deref() - .map(str::trim) - .unwrap_or(""); - let annotated_description = if description.is_empty() { - plugin_source_note - } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { - format!("{description} {plugin_source_note}") - } else { - format!("{description}. {plugin_source_note}") - }; - tool.tool.description = Some(Cow::Owned(annotated_description)); - } - tools - }; - // Keep cache payloads raw; plugin provenance is resolved per-session at read time. let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() { @@ -323,8 +272,19 @@ impl AsyncManagedClient { Ok(client) => Some(client.listed_tools()), Err(_) => self.cached_tool_info_snapshot.clone(), } - }; - tools.map(annotate_tools) + }?; + Some(if self.is_codex_apps_mcp_server { + prepare_codex_apps_tools_for_model(tools, &self.tool_plugin_provenance) + } else { + prepare_regular_mcp_tools_for_model(tools, &self.tool_plugin_provenance) + }) + } + + fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { + if !self.startup_complete.load(Ordering::Acquire) { + return self.cached_tool_info_snapshot.clone(); + } + None } } @@ -348,6 +308,7 @@ impl From for StartupOutcomeError { pub(crate) async fn list_tools_for_client_uncached( server_name: &str, + is_codex_apps_mcp_server: bool, client: &Arc, timeout: Option, server_instructions: Option<&str>, @@ -359,68 +320,165 @@ pub(crate) async fn list_tools_for_client_uncached( .tools .into_iter() .map(|tool| { - let mut tool_def = tool.tool; - let (connector_id, connector_name, connector_description) = - sanitize_tool_connector_metadata( - server_name, - &mut tool_def, - tool.connector_id, - tool.connector_name, - tool.connector_description, - ); - let callable_name = normalize_codex_apps_callable_name( + tool_info_from_listed_tool( server_name, - &tool_def.name, - connector_id.as_deref(), - connector_name.as_deref(), - ); - let callable_namespace = - normalize_codex_apps_callable_namespace(server_name, connector_name.as_deref()); - if let Some(title) = tool_def.title.as_deref() { - let normalized_title = - normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title); - if tool_def.title.as_deref() != Some(normalized_title.as_str()) { - tool_def.title = Some(normalized_title); - } - } - let has_connector_metadata = connector_id.is_some() - || connector_name.is_some() - || connector_description.is_some(); - let namespace_description = if has_connector_metadata { - connector_description - } else { - server_instructions.map(str::to_string) - }; - ToolInfo { - server_name: server_name.to_owned(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name, - callable_namespace, - namespace_description, - tool: tool_def, - connector_id, - connector_name, - plugin_display_names: Vec::new(), - } + is_codex_apps_mcp_server, + server_instructions, + tool, + ) }) .collect(); Ok(tools) } -fn sanitize_tool_connector_metadata( - server_name: &str, - tool: &mut RmcpTool, - connector_id: Option, - connector_name: Option, - connector_description: Option, -) -> (Option, Option, Option) { - if server_name == CODEX_APPS_MCP_SERVER_NAME { - return (connector_id, connector_name, connector_description); +/// Presents declared Codex Apps file parameters to the model as local-path inputs and adds plugin +/// names to each tool. Plugin membership is resolved by connector ID, falling back to the MCP +/// server when absent. +fn prepare_codex_apps_tools_for_model( + mut tools: Vec, + tool_plugin_provenance: &ToolPluginProvenance, +) -> Vec { + for tool in &mut tools { + tool.tool = tool_with_model_visible_input_schema(&tool.tool); + let plugin_names = match tool.connector_id.as_deref() { + Some(connector_id) => { + tool_plugin_provenance.plugin_display_names_for_connector_id(connector_id) + } + None => tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), + }; + add_plugin_provenance_to_tool(tool, plugin_names); + } + tools +} + +/// Stores plugin names on the tool and appends a model-visible plugin membership note. +fn add_plugin_provenance_to_tool(tool: &mut ToolInfo, plugin_names: &[String]) { + tool.plugin_display_names = plugin_names.to_vec(); + if plugin_names.is_empty() { + return; } - strip_untrusted_connector_meta(tool); - (None, None, None) + let plugin_source_note = if plugin_names.len() == 1 { + format!("This tool is part of plugin `{}`.", plugin_names[0]) + } else { + format!( + "This tool is part of plugins {}.", + plugin_names + .iter() + .map(|plugin_name| format!("`{plugin_name}`")) + .collect::>() + .join(", ") + ) + }; + let description = tool + .tool + .description + .as_deref() + .map(str::trim) + .unwrap_or(""); + let annotated_description = if description.is_empty() { + plugin_source_note + } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { + format!("{description} {plugin_source_note}") + } else { + format!("{description}. {plugin_source_note}") + }; + tool.tool.description = Some(Cow::Owned(annotated_description)); +} + +/// Adds server-scoped plugin names to regular MCP tools without changing their input schemas. +fn prepare_regular_mcp_tools_for_model( + mut tools: Vec, + tool_plugin_provenance: &ToolPluginProvenance, +) -> Vec { + for tool in &mut tools { + let plugin_names = tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()); + add_plugin_provenance_to_tool(tool, plugin_names); + } + tools +} + +fn tool_info_from_listed_tool( + server_name: &str, + is_codex_apps_mcp_server: bool, + server_instructions: Option<&str>, + tool: ToolWithConnectorId, +) -> ToolInfo { + if is_codex_apps_mcp_server { + codex_apps_tool_info_from_listed_tool(server_name, server_instructions, tool) + } else { + regular_mcp_tool_info_from_listed_tool(server_name, server_instructions, tool) + } +} + +/// Converts a Codex Apps tool by preserving connector fields, removing connector prefixes from +/// model-visible names and titles, and using the connector description for its tool namespace. +fn codex_apps_tool_info_from_listed_tool( + server_name: &str, + server_instructions: Option<&str>, + tool: ToolWithConnectorId, +) -> ToolInfo { + let mut tool_def = tool.tool; + let connector_id = tool.connector_id; + let connector_name = tool.connector_name; + let connector_description = tool.connector_description; + let callable_name = normalize_codex_apps_callable_name( + &tool_def.name, + connector_id.as_deref(), + connector_name.as_deref(), + ); + let callable_namespace = + normalize_codex_apps_callable_namespace(server_name, connector_name.as_deref()); + if let Some(title) = tool_def.title.as_deref() { + let normalized_title = normalize_codex_apps_tool_title(connector_name.as_deref(), title); + if tool_def.title.as_deref() != Some(normalized_title.as_str()) { + tool_def.title = Some(normalized_title); + } + } + let has_connector_metadata = + connector_id.is_some() || connector_name.is_some() || connector_description.is_some(); + let namespace_description = if has_connector_metadata { + connector_description + } else { + server_instructions.map(str::to_string) + }; + ToolInfo { + server_name: server_name.to_owned(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name, + callable_namespace, + namespace_description, + tool: tool_def, + connector_id, + connector_name, + plugin_display_names: Vec::new(), + } +} + +/// Converts a regular MCP tool by removing reserved connector metadata, keeping its raw tool name, +/// and using the MCP server name and instructions for the model-visible namespace. +fn regular_mcp_tool_info_from_listed_tool( + server_name: &str, + server_instructions: Option<&str>, + tool: ToolWithConnectorId, +) -> ToolInfo { + let mut tool_def = tool.tool; + strip_untrusted_connector_meta(&mut tool_def); + ToolInfo { + server_name: server_name.to_owned(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: tool_def.name.to_string(), + callable_namespace: server_name.to_string(), + namespace_description: server_instructions.map(str::to_string), + tool: tool_def, + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + } } fn strip_untrusted_connector_meta(tool: &mut RmcpTool) { @@ -477,6 +535,7 @@ async fn start_server_task( params: StartServerTaskParams, ) -> Result { let StartServerTaskParams { + is_codex_apps_mcp_server, startup_timeout, tool_timeout, tool_filter, @@ -508,6 +567,7 @@ async fn start_server_task( let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached( &server_name, + is_codex_apps_mcp_server, &client, startup_timeout, initialize_result.instructions.as_deref(), @@ -520,19 +580,21 @@ async fn start_server_task( &[], ); let server_info = mcp_server_info_from_implementation(initialize_result.server_info); - write_cached_codex_apps_tools_if_needed( - &server_name, - codex_apps_tools_cache_context.as_ref(), - &server_info, - &tools, - ); - if server_name == CODEX_APPS_MCP_SERVER_NAME { + let codex_apps_tools_cache_context = if is_codex_apps_mcp_server { + write_codex_apps_tools_cache( + codex_apps_tools_cache_context.as_ref(), + &server_info, + &tools, + ); emit_duration( MCP_TOOLS_LIST_DURATION_METRIC, list_start.elapsed(), &[("cache", "miss")], ); - } + codex_apps_tools_cache_context + } else { + None + }; let tools = filter_tools(tools, &tool_filter); let managed = ManagedClient { @@ -585,6 +647,7 @@ fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServer } struct StartServerTaskParams { + is_codex_apps_mcp_server: bool, startup_timeout: Option, // TODO: cancel_token should handle this. tool_timeout: Duration, tool_filter: ToolFilter, @@ -645,6 +708,7 @@ async fn make_rmcp_client( )) as Arc }; + let cwd = cwd.map(codex_utils_path_uri::LegacyAppPathString::into_string); RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher) .await .map_err(|err| StartupOutcomeError::from(anyhow!(err))) @@ -684,6 +748,7 @@ async fn make_rmcp_client( #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Meta; @@ -736,18 +801,7 @@ mod tests { fn custom_mcp_connector_metadata_is_stripped() { let mut tool = tool_with_connector_meta(); - let (connector_id, connector_name, connector_description) = - sanitize_tool_connector_metadata( - "minimaltest", - &mut tool, - Some("connector_gmail".to_string()), - Some("Gmail".to_string()), - Some("Mail connector".to_string()), - ); - - assert_eq!(connector_id, None); - assert_eq!(connector_name, None); - assert_eq!(connector_description, None); + strip_untrusted_connector_meta(&mut tool); let meta = tool.meta.as_ref().expect("meta"); for key in [ @@ -770,32 +824,36 @@ mod tests { #[test] fn codex_apps_connector_metadata_is_preserved() { - let mut tool = tool_with_connector_meta(); + let tool = tool_with_connector_meta(); + let expected_tool = tool.clone(); - let (connector_id, connector_name, connector_description) = - sanitize_tool_connector_metadata( - CODEX_APPS_MCP_SERVER_NAME, - &mut tool, - Some("connector_gmail".to_string()), - Some("Gmail".to_string()), - Some("Mail connector".to_string()), - ); + let tool_info = tool_info_from_listed_tool( + CODEX_APPS_MCP_SERVER_NAME, + /*is_codex_apps_mcp_server*/ true, + /*server_instructions*/ None, + ToolWithConnectorId { + tool, + connector_id: Some("connector_gmail".to_string()), + connector_name: Some("Gmail".to_string()), + connector_description: Some("Mail connector".to_string()), + }, + ); - assert_eq!(connector_id.as_deref(), Some("connector_gmail")); - assert_eq!(connector_name.as_deref(), Some("Gmail")); - assert_eq!(connector_description.as_deref(), Some("Mail connector")); - - let meta = tool.meta.as_ref().expect("meta"); - for key in [ - "connector_id", - "connector_name", - "connector_display_name", - "connector_description", - "connectorDescription", - "connectorFutureField", - "CONNECTOR_UPPERCASE", - ] { - assert!(meta.0.contains_key(key), "{key} should be preserved"); - } + let expected = ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: "capture_file_upload".to_string(), + callable_namespace: "codex_apps__gmail".to_string(), + namespace_description: Some("Mail connector".to_string()), + tool: expected_tool, + connector_id: Some("connector_gmail".to_string()), + connector_name: Some("Gmail".to_string()), + plugin_display_names: Vec::new(), + }; + assert_eq!( + serde_json::to_value(tool_info).expect("serialize actual tool info"), + serde_json::to_value(expected).expect("serialize expected tool info") + ); } } diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index ff5c65db00..e48bdc7f29 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -17,11 +17,10 @@ use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxState { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub permission_profile: Option, + pub permission_profile: PermissionProfile, pub codex_linux_sandbox_exe: Option, pub sandbox_cwd: PathUri, #[serde(default)] @@ -66,9 +65,6 @@ impl McpRuntimeContext { .environment_manager .get_environment(&config.environment_id) { - if !config.is_local_environment() { - ensure_remote_stdio_cwd(server_name, config)?; - } return Ok(Some(environment)); } @@ -88,27 +84,6 @@ impl McpRuntimeContext { } } -fn ensure_remote_stdio_cwd( - server_name: &str, - config: &codex_config::McpServerConfig, -) -> Result<(), String> { - let codex_config::McpServerTransportConfig::Stdio { cwd, .. } = &config.transport else { - return Ok(()); - }; - let Some(cwd) = cwd else { - return Err(format!( - "remote stdio MCP server `{server_name}` requires an absolute cwd" - )); - }; - if cwd.is_absolute() { - return Ok(()); - } - Err(format!( - "remote stdio MCP server `{server_name}` requires an absolute cwd, got `{}`", - cwd.display() - )) -} - pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { if let Some(metrics) = codex_otel::global() { let _ = metrics.record_duration(metric, duration, tags); @@ -123,6 +98,7 @@ mod tests { use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_exec_server::EnvironmentManager; + use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use super::*; @@ -236,7 +212,7 @@ mod tests { let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { unreachable!("stdio helper should build stdio transport"); }; - *cwd = Some(std::env::temp_dir()); + *cwd = Some(LegacyAppPathString::from_path(&std::env::temp_dir())); for resolved_runtime in [ runtime_context.resolve_server_environment("stdio", &remote_stdio), runtime_context.resolve_server_environment("http", &http_server("remote")), @@ -264,32 +240,4 @@ mod tests { }; assert!(resolved_runtime.is_some()); } - - #[tokio::test] - async fn remote_stdio_requires_absolute_cwd() { - let runtime_context = McpRuntimeContext::new( - Arc::new( - EnvironmentManager::create_for_tests( - Some("ws://127.0.0.1:8765".to_string()), - /*local_runtime_paths*/ None, - ) - .await, - ), - PathBuf::from("/tmp"), - ); - let mut remote_stdio = stdio_server("remote"); - let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { - unreachable!("stdio helper should build stdio transport"); - }; - *cwd = Some(PathBuf::from("relative")); - - let error = match runtime_context.resolve_server_environment("stdio", &remote_stdio) { - Ok(_) => panic!("remote stdio MCP should require absolute cwd"), - Err(error) => error, - }; - assert_eq!( - error, - "remote stdio MCP server `stdio` requires an absolute cwd, got `relative`" - ); - } } diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index 83cbfd5e74..f63ab002df 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -113,9 +113,9 @@ impl ToolFilter { } } -/// Returns the model-visible view of a tool while preserving the raw metadata -/// used by execution. Keep cache entries raw and call this at manager return -/// boundaries. +/// Returns the model-visible view of a tool while preserving the raw metadata used by execution. +/// Declared file parameters are presented as local file paths; execution later uploads those files +/// and replaces the paths with the uploaded-file objects expected by the app. pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); if file_params.is_empty() { @@ -124,7 +124,7 @@ pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { let mut tool = tool.clone(); let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone()); - mask_input_schema_for_file_path_params(&mut input_schema, &file_params); + rewrite_input_schema_for_local_file_paths(&mut input_schema, &file_params); if let JsonValue::Object(input_schema) = input_schema { tool.input_schema = Arc::new(input_schema); } @@ -262,15 +262,7 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; const CALLABLE_NAME_HASH_LEN: usize = 12; const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; -fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String { - if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { - namespace.to_string() - } else { - format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{namespace}") - } -} - -fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_params: &[String]) { +fn rewrite_input_schema_for_local_file_paths(input_schema: &mut JsonValue, file_params: &[String]) { let Some(properties) = input_schema .as_object_mut() .and_then(|schema| schema.get_mut("properties")) @@ -283,11 +275,11 @@ fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_par let Some(property_schema) = properties.get_mut(field_name) else { continue; }; - mask_input_property_schema(property_schema); + rewrite_input_property_schema_as_local_file_path(property_schema); } } -fn mask_input_property_schema(schema: &mut JsonValue) { +fn rewrite_input_property_schema_as_local_file_path(schema: &mut JsonValue) { let Some(object) = schema.as_object_mut() else { return; }; @@ -316,6 +308,14 @@ fn mask_input_property_schema(schema: &mut JsonValue) { } } +fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String { + if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { + namespace.to_string() + } else { + format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{namespace}") + } +} + fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); hasher.update(s.as_bytes()); diff --git a/codex-rs/collaboration-mode-templates/templates/plan.md b/codex-rs/collaboration-mode-templates/templates/plan.md index 8a1a934f83..ca68f41c89 100644 --- a/codex-rs/collaboration-mode-templates/templates/plan.md +++ b/codex-rs/collaboration-mode-templates/templates/plan.md @@ -125,4 +125,4 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one `` block per turn, and only when you are presenting a complete spec. -If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. +If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a `` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior `` unchanged. diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index 9f007de113..1d896e5f31 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -146,7 +146,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { entry["env_vars"] = array_from_env_vars(env_vars); } if let Some(cwd) = cwd { - entry["cwd"] = value(cwd.to_string_lossy().to_string()); + entry["cwd"] = value(cwd.as_str()); } } McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 0d30ba8b23..42142212c5 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use std::fmt; -use std::path::PathBuf; use std::time::Duration; +use codex_utils_path_uri::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; @@ -224,7 +224,7 @@ pub struct RawMcpServerConfig { #[serde(default)] pub env_vars: Option>, #[serde(default)] - pub cwd: Option, + pub cwd: Option, pub http_headers: Option>, #[serde(default)] pub env_http_headers: Option>, @@ -358,7 +358,6 @@ impl TryFrom for McpServerConfig { let environment_id = environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string()); - validate_remote_stdio_cwd(&transport, &environment_id)?; Ok(Self { transport, @@ -395,30 +394,6 @@ const fn default_enabled() -> bool { true } -fn validate_remote_stdio_cwd( - transport: &McpServerTransportConfig, - environment_id: &str, -) -> Result<(), String> { - if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID { - return Ok(()); - } - let McpServerTransportConfig::Stdio { cwd, .. } = transport else { - return Ok(()); - }; - let Some(cwd) = cwd else { - return Err(format!( - "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`" - )); - }; - if cwd.is_absolute() { - return Ok(()); - } - Err(format!( - "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`", - cwd.display() - )) -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] pub enum McpServerTransportConfig { @@ -432,7 +407,7 @@ pub enum McpServerTransportConfig { #[serde(default, skip_serializing_if = "Vec::is_empty")] env_vars: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - cwd: Option, + cwd: Option, }, /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http StreamableHttp { diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index 5f3933ce43..d3ec9d77e6 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -1,7 +1,8 @@ use super::*; +use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::Path; #[test] fn deserialize_stdio_command_server_config() { @@ -52,38 +53,12 @@ fn deserialize_stdio_command_server_config_with_args() { } #[test] -fn deserialize_remote_stdio_server_requires_absolute_cwd() { - let missing_cwd = toml::from_str::( - r#" - command = "echo" - environment_id = "remote" - "#, - ) - .expect_err("remote stdio MCP should require cwd"); - assert!( - missing_cwd - .to_string() - .contains("remote stdio MCP servers require an absolute cwd"), - "unexpected error: {missing_cwd}" - ); - - let relative_cwd = toml::from_str::( - r#" - command = "echo" - environment_id = "remote" - cwd = "relative" - "#, - ) - .expect_err("remote stdio MCP should require absolute cwd"); - assert!( - relative_cwd.to_string().contains("got `relative`"), - "unexpected error: {relative_cwd}" - ); -} - -#[test] -fn deserialize_remote_stdio_server_accepts_absolute_cwd() { - let cwd = std::env::temp_dir(); +fn deserialize_remote_stdio_server_accepts_foreign_absolute_cwd() { + #[cfg(not(windows))] + let cwd = r"C:\Users\openai\share"; + #[cfg(windows)] + let cwd = "/home/openai/share"; + let expected_cwd = LegacyAppPathString::from_path(Path::new(cwd)); let cfg: McpServerConfig = match toml::from_str(&format!( r#" command = "echo" @@ -102,7 +77,7 @@ fn deserialize_remote_stdio_server_accepts_absolute_cwd() { args: vec![], env: None, env_vars: Vec::new(), - cwd: Some(cwd), + cwd: Some(expected_cwd), } ); } @@ -223,7 +198,7 @@ fn deserialize_stdio_command_server_config_with_cwd() { args: vec![], env: None, env_vars: Vec::new(), - cwd: Some(PathBuf::from("/tmp")), + cwd: Some(LegacyAppPathString::from_path(Path::new("/tmp"))), } ); } diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index fad074e9c3..8547269ce2 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -44,6 +44,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::TokenBudget { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::RolloutBudget { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/context-fragments/src/fragment.rs b/codex-rs/context-fragments/src/fragment.rs index 38714e2512..5f44e33530 100644 --- a/codex-rs/context-fragments/src/fragment.rs +++ b/codex-rs/context-fragments/src/fragment.rs @@ -83,7 +83,7 @@ pub trait ContextualUserFragment { text: self.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -95,7 +95,7 @@ pub trait ContextualUserFragment { text: self.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 54eb371686..2a8d44fc97 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -38,7 +38,6 @@ use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_plugin::PluginIdError; -use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; @@ -1164,13 +1163,11 @@ fn cleaned_app_category(category: Option) -> Option { .filter(|category| !category.is_empty()) } -pub async fn plugin_telemetry_metadata_from_root( +pub async fn plugin_capability_summary_from_root( plugin_id: &PluginId, plugin_root: &AbsolutePathBuf, -) -> PluginTelemetryMetadata { - let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; +) -> Option { + let manifest = load_plugin_manifest(plugin_root.as_path())?; let manifest_paths = &manifest.paths; let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty(); @@ -1192,18 +1189,14 @@ pub async fn plugin_telemetry_metadata_from_root( .await; let app_connector_ids = app_connector_ids_from_declarations(&app_declarations); - PluginTelemetryMetadata { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: Some(PluginCapabilitySummary { - config_name: plugin_id.as_key(), - display_name: plugin_id.plugin_name.clone(), - description: None, - has_skills, - mcp_server_names, - app_connector_ids, - }), - } + Some(PluginCapabilitySummary { + config_name: plugin_id.as_key(), + display_name: plugin_id.plugin_name.clone(), + description: None, + has_skills, + mcp_server_names, + app_connector_ids, + }) } pub async fn load_plugin_mcp_servers( @@ -1279,24 +1272,6 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest( mcp_servers } -pub async fn installed_plugin_telemetry_metadata( - codex_home: &Path, - plugin_id: &PluginId, -) -> PluginTelemetryMetadata { - let store = match PluginStore::try_new(codex_home.to_path_buf()) { - Ok(store) => store, - Err(err) => { - warn!("failed to resolve plugin cache root: {err}"); - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - } - }; - let Some(plugin_root) = store.active_plugin_root(plugin_id) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; - - plugin_telemetry_metadata_from_root(plugin_id, &plugin_root).await -} - async fn load_mcp_servers_from_file( plugin_root: &Path, mcp_config_path: &AbsolutePathBuf, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b502106ea9..19b48b2c01 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -6,7 +6,6 @@ use crate::is_openai_curated_marketplace_name; use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; -use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_hooks_from_layer_stack; @@ -15,7 +14,7 @@ use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::log_plugin_load_errors; use crate::loader::materialize_marketplace_plugin_source; -use crate::loader::plugin_telemetry_metadata_from_root; +use crate::loader::plugin_capability_summary_from_root; use crate::loader::refresh_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; @@ -709,6 +708,66 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + pub async fn telemetry_metadata_for_installed_plugin( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + let mut metadata = self.telemetry_metadata_for_plugin_id(plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub async fn telemetry_metadata_for_installed_plugin_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + let mut metadata = + self.telemetry_metadata_for_plugin_id_with_remote_id(plugin_id, remote_plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub fn telemetry_metadata_for_plugin_id( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + plugin_id: plugin_id.clone(), + remote_plugin_id: None, + capability_summary: None, + } + } + + pub fn telemetry_metadata_for_plugin_id_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + remote_plugin_id: Some(remote_plugin_id.to_string()), + ..self.telemetry_metadata_for_plugin_id(plugin_id) + } + } + + pub fn telemetry_metadata_for_capability_summary( + &self, + summary: &PluginCapabilitySummary, + ) -> Option { + let plugin_id = PluginId::parse(&summary.config_name).ok()?; + Some(PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(summary.clone()), + }) + } + pub fn build_remote_installed_plugin_marketplaces_from_cache( &self, visible_marketplaces: &[&str], @@ -1281,7 +1340,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_install_failed( - PluginTelemetryMetadata::from_plugin_id(plugin_id), + self.telemetry_metadata_for_plugin_id(plugin_id), error_type.to_string(), ); } @@ -1351,7 +1410,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_installed( - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path) + self.telemetry_metadata_for_installed_plugin(&result.plugin_id) .await, ); } @@ -1392,7 +1451,10 @@ impl PluginsManager { async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() { - Some(installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id).await) + Some( + self.telemetry_metadata_for_installed_plugin(&plugin_id) + .await, + ) } else { None }; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 4f448ff160..adf721fc6e 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -845,6 +845,81 @@ remote_plugin = true assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn installed_plugin_telemetry_metadata_collects_capabilities() { + let codex_home = TempDir::new().unwrap(); + write_cached_plugin(codex_home.path(), "test", "sample"); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }), + } + ); +} + +#[tokio::test] +async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear") + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: Some("plugins~Plugin_linear".to_string()), + capability_summary: None, + } + ); +} + +#[test] +fn capability_summary_telemetry_metadata_uses_local_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let summary = PluginCapabilitySummary { + config_name: "linear@openai-curated-remote".to_string(), + display_name: "Linear".to_string(), + description: Some("Track work".to_string()), + has_skills: true, + mcp_server_names: vec!["linear".to_string()], + app_connector_ids: vec![AppConnectorId("linear-app".to_string())], + }; + + let metadata = manager.telemetry_metadata_for_capability_summary(&summary); + + assert_eq!( + metadata, + Some(PluginTelemetryMetadata { + plugin_id: PluginId::parse("linear@openai-curated-remote") + .expect("plugin id should parse"), + remote_plugin_id: None, + capability_summary: Some(summary), + }) + ); +} + #[tokio::test] async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { let codex_home = TempDir::new().unwrap(); @@ -957,7 +1032,9 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), }); @@ -1154,14 +1231,14 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "sample".to_string(), @@ -1174,7 +1251,7 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { } #[tokio::test] -async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { +async fn plugin_capability_summary_uses_manifest_mcp_server_objects() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -1195,14 +1272,14 @@ async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("counter-sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "counter-sample@test".to_string(), display_name: "counter-sample".to_string(), diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 37630a8436..2352fdbefb 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -74,6 +74,8 @@ struct RawPluginManifestInterface { #[serde(default)] logo: Option, #[serde(default)] + logo_dark: Option, + #[serde(default)] screenshots: Vec, } @@ -175,6 +177,7 @@ pub(crate) fn parse_plugin_manifest( brand_color, composer_icon, logo, + logo_dark, screenshots, } = interface; @@ -196,6 +199,11 @@ pub(crate) fn parse_plugin_manifest( composer_icon.as_deref(), ), logo: resolve_interface_asset_path(plugin_root, "interface.logo", logo.as_deref()), + logo_dark: resolve_interface_asset_path( + plugin_root, + "interface.logoDark", + logo_dark.as_deref(), + ), screenshots: screenshots .iter() .filter_map(|screenshot| { @@ -221,6 +229,7 @@ pub(crate) fn parse_plugin_manifest( || interface.brand_color.is_some() || interface.composer_icon.is_some() || interface.logo.is_some() + || interface.logo_dark.is_some() || !interface.screenshots.is_empty(); has_fields.then_some(interface) @@ -594,6 +603,32 @@ mod tests { assert_eq!(interface.default_prompt, None); } + #[test] + fn plugin_interface_reads_dark_logo_path() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + /*version*/ None, + r#"{ + "logoDark": "./assets/logo-dark.svg" + }"#, + ); + + let manifest = load_manifest(&plugin_root); + let interface = manifest.interface.expect("plugin interface"); + + assert_eq!( + interface.logo_dark, + Some( + AbsolutePathBuf::from_absolute_path_checked( + plugin_root.join("assets/logo-dark.svg"), + ) + .expect("absolute dark logo path") + ) + ); + } + #[test] fn plugin_manifest_reads_trimmed_version() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 456bd9004c..f5024ab25c 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -103,6 +103,7 @@ impl MarketplacePluginManifestFallback { if let Some(interface) = manifest.interface.as_mut() { interface.composer_icon = None; interface.logo = None; + interface.logo_dark = None; interface.screenshots.clear(); } Some(manifest) diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 45aa2364e2..3ea2c9d606 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -180,6 +180,51 @@ fn find_marketplace_plugin_supports_git_subdir_sources() { ); } +#[test] +fn find_marketplace_plugin_omits_interface_asset_paths_for_git_sources() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "remote-plugin", + "source": { + "source": "git-subdir", + "url": "openai/joey_marketplace3", + "path": "plugins/toolkit" + }, + "interface": { + "displayName": "Remote Plugin", + "composerIcon": "./assets/icon.svg", + "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", + "screenshots": ["./assets/shot.png"] + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "remote-plugin", + ) + .unwrap(); + + let interface = resolved.interface.expect("fallback interface"); + assert_eq!(interface.display_name.as_deref(), Some("Remote Plugin")); + assert_eq!(interface.composer_icon, None); + assert_eq!(interface.logo, None); + assert_eq!(interface.logo_dark, None); + assert!(interface.screenshots.is_empty()); +} + #[test] fn find_marketplace_plugin_builds_manifest_fallback_from_entry() { let tmp = tempdir().unwrap(); @@ -319,6 +364,7 @@ fn find_marketplace_plugin_builds_manifest_fallback_from_entry() { AbsolutePathBuf::try_from(plugin_root.join("assets/icon.svg")).unwrap() ), logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + logo_dark: None, screenshots: vec![ AbsolutePathBuf::try_from(plugin_root.join("assets/shot.png")).unwrap() ], @@ -634,6 +680,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), }), keywords: Vec::new(), @@ -720,6 +767,7 @@ fn list_marketplaces_supports_repo_root_local_plugin_sources() { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), }), keywords: Vec::new(), @@ -1625,6 +1673,7 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(), ), logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + logo_dark: None, screenshots: vec![ AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(), ], @@ -1739,6 +1788,7 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), }) ); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index a0007aa1df..05ce110953 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -463,6 +463,7 @@ struct RemotePluginReleaseInterfaceResponse { default_prompts: Option>, composer_icon_url: Option, logo_url: Option, + logo_url_dark: Option, #[serde(default)] screenshot_urls: Vec, } @@ -1529,7 +1530,9 @@ fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option composer_icon: None, composer_icon_url: interface.composer_icon_url.clone(), logo: None, + logo_dark: None, logo_url: interface.logo_url.clone(), + logo_url_dark: interface.logo_url_dark.clone(), screenshots: Vec::new(), screenshot_urls: interface.screenshot_urls.clone(), }; @@ -1546,6 +1549,7 @@ fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option || result.brand_color.is_some() || result.composer_icon_url.is_some() || result.logo_url.is_some() + || result.logo_url_dark.is_some() || !result.screenshot_urls.is_empty(); has_fields.then_some(result) } diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 8041d444b5..2a23b066ed 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -212,6 +212,16 @@ pub async fn sync_remote_installed_plugin_bundles_once( .map(str::trim) .filter(|version| !version.is_empty()); if store.active_plugin_version(&plugin_id).as_deref() == release_version { + if let Err(err) = store.write_remote_plugin_id(&plugin_id, &plugin.id) { + warn!( + remote_plugin_id = %plugin.id, + plugin = %plugin.name, + marketplace = %marketplace_name, + error = %err, + "failed to persist identity for cached remote installed plugin" + ); + failed_remote_plugin_ids.insert(plugin.id); + } continue; } @@ -439,6 +449,13 @@ fn clear_remote_installed_plugin_bundle_sync_in_flight(key: &RemoteInstalledPlug mod tests { use super::*; use pretty_assertions::assert_eq; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; #[test] fn remote_installed_plugin_sync_in_flight_dedupes_by_cache_root() { @@ -461,6 +478,105 @@ mod tests { clear_remote_installed_plugin_bundle_sync_in_flight(&key); } + #[tokio::test] + async fn sync_backfills_remote_plugin_install_metadata_for_current_bundle() { + let server = MockServer::start().await; + let codex_home = tempfile::tempdir().expect("create codex home"); + let cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_GLOBAL_MARKETPLACE_NAME) + .join("linear") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent")) + .expect("create cached plugin manifest parent"); + std::fs::write(&cached_manifest, r#"{"name":"linear","version":"1.2.3"}"#) + .expect("write cached plugin manifest"); + let remote_plugin_id = "plugins~Plugin_linear"; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [{ + "id": remote_plugin_id, + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "version": "1.2.3", + "display_name": "Linear", + "description": "Track work", + "interface": {}, + }, + "enabled": true, + }], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "USER")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + let config = RemotePluginServiceConfig { + chatgpt_base_url: format!("{}/backend-api", server.uri()), + }; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let outcome = sync_remote_installed_plugin_bundles_once( + codex_home.path().to_path_buf(), + &config, + Some(&auth), + ) + .await + .expect("sync current remote plugin bundle"); + + assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default()); + let plugin_id = PluginId::new( + "linear".to_string(), + REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), + ) + .expect("valid plugin id"); + let metadata_path = PluginStore::new(codex_home.path().to_path_buf()) + .plugin_base_root(&plugin_id) + .join(".codex-remote-plugin-install.json"); + assert_eq!( + serde_json::from_str::( + &std::fs::read_to_string(metadata_path.as_path()) + .expect("read remote plugin install metadata") + ) + .expect("parse remote plugin install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": remote_plugin_id, + }) + ); + } + #[test] fn stale_remote_plugin_cleanup_skips_cache_mutations_in_progress() { let codex_home = tempfile::tempdir().expect("create codex home"); diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index e33d3c5d51..ed48341237 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -156,7 +156,9 @@ fn expected_plugin_interface() -> PluginInterface { composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), } diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 49e6bd4138..66592dc363 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -34,6 +34,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = pub struct ValidatedRemotePluginBundle { pub plugin_id: PluginId, pub plugin_version: String, + remote_plugin_id: String, app_manifest: Option, bundle_download_url: String, } @@ -190,6 +191,7 @@ pub fn validate_remote_plugin_bundle( Ok(ValidatedRemotePluginBundle { plugin_id, plugin_version, + remote_plugin_id: remote_plugin_id.to_string(), app_manifest, bundle_download_url, }) @@ -403,9 +405,12 @@ fn install_remote_plugin_bundle( })?; let store = PluginStore::try_new(codex_home)?; - store + let remote_plugin_id = bundle.remote_plugin_id; + let result = store .install_with_version(plugin_root, bundle.plugin_id, bundle.plugin_version) - .map_err(RemotePluginBundleInstallError::from) + .map_err(RemotePluginBundleInstallError::from)?; + store.write_remote_plugin_id(&result.plugin_id, &remote_plugin_id)?; + Ok(result) } fn extract_remote_plugin_bundle_to_path( @@ -745,6 +750,43 @@ mod tests { ); } + #[test] + fn install_persists_remote_plugin_install_metadata() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let result = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[( + ".codex-plugin/plugin.json", + br#"{"name":"linear","version":"1.2.3"}"#, + /*mode*/ 0o644, + )]), + ) + .expect("install bundle"); + let store = PluginStore::new(codex_home.path().to_path_buf()); + + assert_eq!( + store.remote_plugin_id(&result.plugin_id).unwrap(), + Some(REMOTE_PLUGIN_ID.to_string()) + ); + let metadata_path = store + .plugin_base_root(&result.plugin_id) + .join(".codex-remote-plugin-install.json"); + assert_eq!( + serde_json::from_str::( + &std::fs::read_to_string(metadata_path.as_path()) + .expect("read remote plugin install metadata") + ) + .expect("parse remote plugin install metadata"), + serde_json::json!({ + "schema_version": 1, + "remote_plugin_id": REMOTE_PLUGIN_ID, + }) + ); + } + #[test] fn install_preserves_non_global_bundle_manifest_metadata() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index 793a8a55bd..f1d4478d7f 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -69,6 +69,7 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { default_prompts: None, composer_icon_url: None, logo_url: None, + logo_url_dark: None, screenshot_urls: Vec::new(), }, skills: Vec::new(), @@ -76,6 +77,20 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { }, } } + +#[test] +fn remote_plugin_interface_maps_dark_logo_url() { + let mut plugin = directory_plugin("plugin-linear", "linear"); + plugin.release.interface.logo_url_dark = + Some("https://example.com/linear/logo-dark.png".to_string()); + + assert_eq!( + remote_plugin_interface_to_info(&plugin) + .expect("plugin interface") + .logo_url_dark, + Some("https://example.com/linear/logo-dark.png".to_string()) + ); +} fn item(name: &str, display_name: &str) -> RecommendedPluginItem { RecommendedPluginItem { id: format!("plugin_{name}"), diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index f0c737e28d..5d78fefa90 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -7,16 +7,26 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; use semver::Version; use serde::Deserialize; +use serde::Serialize; use serde_json::Value as JsonValue; use std::cmp::Ordering; use std::fs; use std::io; +use std::io::Write; use std::path::Path; use std::path::PathBuf; pub const DEFAULT_PLUGIN_VERSION: &str = "local"; pub const PLUGINS_CACHE_DIR: &str = "plugins/cache"; pub const PLUGINS_DATA_DIR: &str = "plugins/data"; +const REMOTE_PLUGIN_INSTALL_METADATA_FILE: &str = ".codex-remote-plugin-install.json"; +const REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION: u8 = 1; + +#[derive(Debug, Deserialize, Serialize)] +struct RemotePluginInstallMetadata { + schema_version: u8, + remote_plugin_id: String, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallResult { @@ -106,6 +116,102 @@ impl PluginStore { self.active_plugin_version(plugin_id).is_some() } + pub fn remote_plugin_id( + &self, + plugin_id: &PluginId, + ) -> Result, PluginStoreError> { + if !self.is_installed(plugin_id) { + return Ok(None); + } + let path = self.remote_plugin_install_metadata_path(plugin_id); + let contents = match fs::read_to_string(path.as_path()) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(PluginStoreError::io( + "failed to read remote plugin install metadata", + err, + )); + } + }; + let metadata: RemotePluginInstallMetadata = + serde_json::from_str(&contents).map_err(|err| { + PluginStoreError::Invalid(format!( + "failed to parse remote plugin install metadata: {err}" + )) + })?; + if metadata.schema_version != REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION { + return Err(PluginStoreError::Invalid(format!( + "unsupported remote plugin install metadata schema version: {}", + metadata.schema_version + ))); + } + let remote_plugin_id = metadata.remote_plugin_id.trim(); + if remote_plugin_id.is_empty() { + return Err(PluginStoreError::Invalid( + "invalid remote plugin install metadata: remote plugin id must not be blank" + .to_string(), + )); + } + Ok(Some(remote_plugin_id.to_string())) + } + + pub fn write_remote_plugin_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> Result<(), PluginStoreError> { + if !self.is_installed(plugin_id) { + return Err(PluginStoreError::Invalid(format!( + "cannot write remote identity for uninstalled plugin `{}`", + plugin_id.as_key() + ))); + } + let remote_plugin_id = remote_plugin_id.trim(); + if remote_plugin_id.is_empty() { + return Err(PluginStoreError::Invalid( + "invalid remote plugin install metadata: remote plugin id must not be blank" + .to_string(), + )); + } + let path = self.remote_plugin_install_metadata_path(plugin_id); + let parent = path.as_path().parent().ok_or_else(|| { + PluginStoreError::Invalid(format!( + "remote plugin install metadata path has no parent: {}", + path.display() + )) + })?; + let mut contents = serde_json::to_vec_pretty(&RemotePluginInstallMetadata { + schema_version: REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION, + remote_plugin_id: remote_plugin_id.to_string(), + }) + .map_err(|err| { + PluginStoreError::Invalid(format!( + "failed to serialize remote plugin install metadata: {err}" + )) + })?; + contents.push(b'\n'); + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|err| { + PluginStoreError::io( + "failed to create temporary remote plugin install metadata", + err, + ) + })?; + temporary.write_all(&contents).map_err(|err| { + PluginStoreError::io("failed to write remote plugin install metadata", err) + })?; + temporary.as_file_mut().flush().map_err(|err| { + PluginStoreError::io("failed to flush remote plugin install metadata", err) + })?; + temporary.persist(path.as_path()).map_err(|err| { + PluginStoreError::io( + "failed to persist remote plugin install metadata", + err.error, + ) + })?; + Ok(()) + } + pub fn install( &self, source_path: AbsolutePathBuf, @@ -197,6 +303,7 @@ impl PluginStore { &plugin_version, manifest, )?; + self.remove_remote_plugin_install_metadata(&plugin_id)?; Ok(PluginInstallResult { plugin_id, @@ -208,6 +315,26 @@ impl PluginStore { pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> { remove_existing_target(self.plugin_base_root(plugin_id).as_path()) } + + fn remote_plugin_install_metadata_path(&self, plugin_id: &PluginId) -> AbsolutePathBuf { + self.plugin_base_root(plugin_id) + .join(REMOTE_PLUGIN_INSTALL_METADATA_FILE) + } + + fn remove_remote_plugin_install_metadata( + &self, + plugin_id: &PluginId, + ) -> Result<(), PluginStoreError> { + let path = self.remote_plugin_install_metadata_path(plugin_id); + match fs::remove_file(path.as_path()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(PluginStoreError::io( + "failed to remove remote plugin install metadata", + err, + )), + } + } } #[derive(Debug, thiserror::Error)] diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index aa9b17cc29..237fb4e88a 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_plugin::PluginId; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::tempdir; fn write_plugin_with_version( @@ -191,6 +192,111 @@ fn install_with_version_uses_requested_cache_version() { assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); } +#[test] +fn remote_plugin_install_metadata_follows_installed_cache_lifecycle() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new( + "sample-plugin".to_string(), + "openai-curated-remote".to_string(), + ) + .unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let source = AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(); + + store + .install(source.clone(), plugin_id.clone()) + .expect("install plugin"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") + .expect("write remote identity"); + let metadata_path = store.remote_plugin_install_metadata_path(&plugin_id); + assert_eq!( + metadata_path.as_path().file_name(), + Some(std::ffi::OsStr::new(".codex-remote-plugin-install.json")) + ); + assert_eq!( + serde_json::from_str::( + &fs::read_to_string(metadata_path.as_path()).expect("read install metadata") + ) + .expect("parse install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": "plugins~Plugin_sample", + }) + ); + assert_eq!( + store.remote_plugin_id(&plugin_id).unwrap(), + Some("plugins~Plugin_sample".to_string()) + ); + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_updated") + .expect("replace remote identity"); + assert_eq!( + store.remote_plugin_id(&plugin_id).unwrap(), + Some("plugins~Plugin_updated".to_string()) + ); + assert_eq!( + serde_json::from_str::( + &fs::read_to_string(metadata_path.as_path()).expect("read updated install metadata") + ) + .expect("parse updated install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": "plugins~Plugin_updated", + }) + ); + + store + .install(source, plugin_id.clone()) + .expect("replace with local install"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + assert!(!metadata_path.as_path().exists()); + + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") + .expect("restore remote identity"); + store.uninstall(&plugin_id).expect("uninstall plugin"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + assert!(!metadata_path.as_path().exists()); +} + +#[test] +fn remote_plugin_install_metadata_rejects_unsupported_schema_version() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new( + "sample-plugin".to_string(), + "openai-curated-remote".to_string(), + ) + .unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) + .expect("install plugin"); + fs::write( + store + .remote_plugin_install_metadata_path(&plugin_id) + .as_path(), + r#"{"schema_version":2,"remote_plugin_id":"plugins~Plugin_sample"}"#, + ) + .expect("write unsupported install metadata"); + + let err = store + .remote_plugin_id(&plugin_id) + .expect_err("unsupported schema version should fail"); + + assert_eq!( + err.to_string(), + "unsupported remote plugin install metadata schema version: 2" + ); +} + #[test] fn install_prefers_on_disk_manifest_version_over_fallback() { let tmp = tempdir().unwrap(); diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index 7748540ac1..3ac82be9a8 100644 --- a/codex-rs/core-skills/Cargo.toml +++ b/codex-rs/core-skills/Cargo.toml @@ -31,6 +31,7 @@ codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } +futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2a3a3189be..b377e909ea 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -22,6 +22,7 @@ use codex_utils_path_uri::PathUri; use codex_utils_plugins::PluginSkillRoot; use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; +use futures::future::join_all; use serde::Deserialize; use std::collections::HashSet; use std::collections::VecDeque; @@ -534,15 +535,29 @@ async fn discover_skills_under_root( } }; - for entry in entries { - let file_name = entry.file_name; - if file_name.starts_with('.') { - continue; - } + let paths = entries + .into_iter() + .filter_map(|entry| { + let file_name = entry.file_name; + if file_name.starts_with('.') { + return None; + } + let path = dir.join(&file_name); + let path_uri = PathUri::from_abs_path(&path); + Some((file_name, path, path_uri)) + }) + .collect::>(); + let metadata_results = join_all( + paths + .iter() + .map(|(_, _, path_uri)| fs.get_metadata(path_uri, /*sandbox*/ None)), + ) + .await; - let path = dir.join(&file_name); - let path_uri = PathUri::from_abs_path(&path); - let metadata = match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + for ((file_name, path, path_uri), metadata_result) in + paths.into_iter().zip(metadata_results) + { + let metadata = match metadata_result { Ok(metadata) => metadata, Err(e) => { error!("failed to stat skills path {}: {e:#}", path.display()); @@ -684,13 +699,8 @@ async fn parse_skill_file( validate_len(&base_name, MAX_NAME_LEN, "name")?; validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?; - validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; - if let Some(short_description) = short_description.as_deref() { - validate_len( - short_description, - MAX_SHORT_DESCRIPTION_LEN, - "metadata.short-description", - )?; + if description.is_empty() { + return Err(SkillParseError::MissingField("description")); } let resolved_path = canonicalize_for_skill_identity(fs, path).await; diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index e22ed33f3d..8cd371c9a7 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1573,7 +1573,7 @@ async fn preserves_block_scalar_body_while_repairing_other_fields() { } #[tokio::test] -async fn enforces_short_description_length_limits() { +async fn preserves_overlong_short_descriptions() { let codex_home = tempfile::tempdir().expect("tempdir"); let skill_dir = codex_home.path().join("skills/demo"); fs::create_dir_all(&skill_dir).unwrap(); @@ -1585,15 +1585,13 @@ async fn enforces_short_description_length_limits() { let cfg = make_config(&codex_home).await; let outcome = load_skills_for_test(&cfg).await; - assert_eq!(outcome.skills.len(), 0); - assert_eq!(outcome.errors.len(), 1); assert!( - outcome.errors[0] - .message - .contains("invalid metadata.short-description"), - "expected length error, got: {:?}", + outcome.errors.is_empty(), + "unexpected errors: {:?}", outcome.errors ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!(outcome.skills[0].short_description, Some(too_long)); } #[tokio::test] @@ -1625,7 +1623,7 @@ async fn skips_hidden_and_invalid() { } #[tokio::test] -async fn enforces_length_limits() { +async fn preserves_overlong_descriptions() { let codex_home = tempfile::tempdir().expect("tempdir"); let max_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN); write_skill(&codex_home, "max-len", "max-len", &max_desc); @@ -1642,12 +1640,18 @@ async fn enforces_length_limits() { let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1); write_skill(&codex_home, "too-long", "too-long", &too_long_desc); let outcome = load_skills_for_test(&cfg).await; - assert_eq!(outcome.skills.len(), 1); - assert_eq!(outcome.errors.len(), 1); assert!( - outcome.errors[0].message.contains("invalid description"), - "expected length error" + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors ); + assert_eq!(outcome.skills.len(), 2); + let too_long_skill = outcome + .skills + .iter() + .find(|skill| skill.name == "too-long") + .expect("too-long skill"); + assert_eq!(too_long_skill.description, too_long_desc); } #[tokio::test] diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 1477a299b6..89ea1675d0 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::path::Component; @@ -16,6 +17,8 @@ use codex_utils_output_truncation::approx_token_count; const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000; const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2; +const MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS: usize = 1_024; +const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "..."; const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100; const APPROX_BYTES_PER_TOKEN: usize = 4; pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."; @@ -446,7 +449,7 @@ impl SkillRenderReport { struct SkillLine<'a> { name: &'a str, - description: &'a str, + description: Cow<'a, str>, path: String, } @@ -485,9 +488,10 @@ impl<'a> SkillLine<'a> { } fn with_path(skill: &'a SkillMetadata, path: String) -> Self { + let description = truncate_default_context_skill_description(skill.description.as_str()); Self { name: skill.name.as_str(), - description: skill.description.as_str(), + description, path, } } @@ -505,7 +509,7 @@ impl<'a> SkillLine<'a> { } fn render_full(&self) -> String { - self.render_with_description(self.description) + self.render_with_description(self.description.as_ref()) } fn render_minimum(&self) -> String { @@ -524,7 +528,7 @@ impl<'a> SkillLine<'a> { format!("- {}: (file: {})", self.name, self.path) } else { let end = self.rendered_description_prefix_len(description_chars); - let description = &self.description[..end]; + let description = &self.description.as_ref()[..end]; format!("- {}: {} (file: {})", self.name, description, self.path) } } @@ -538,6 +542,26 @@ impl<'a> SkillLine<'a> { } } +fn truncate_default_context_skill_description(description: &str) -> Cow<'_, str> { + if description + .char_indices() + .nth(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS) + .is_none() + { + return Cow::Borrowed(description); + } + + let prefix_chars = MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + .saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count()); + let prefix_end = description + .char_indices() + .nth(prefix_chars) + .map_or(description.len(), |(index, _)| index); + let mut truncated = description[..prefix_end].to_string(); + truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX); + Cow::Owned(truncated) +} + impl<'a> DescriptionBudgetLine<'a> { fn new(line: &'a SkillLine<'a>, budget: SkillMetadataBudget) -> Self { let minimum_line = line.render_minimum(); @@ -1030,6 +1054,28 @@ mod tests { ); } + #[test] + fn default_context_caps_descriptions_without_mutating_metadata() { + let description = "\u{1F4A1}".repeat(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + 1); + let skill = make_skill_with_description("long-skill", SkillScope::Repo, &description); + let expected_description = "\u{1F4A1}".repeat( + MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + - TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count(), + ) + TRUNCATED_SKILL_DESCRIPTION_SUFFIX; + + let rendered = build_available_skills_from_metadata( + std::slice::from_ref(&skill), + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("skill should render"); + + assert_eq!(skill.description, description); + assert_eq!( + rendered.skill_lines, + vec![expected_skill_line(&skill, &expected_description)] + ); + } + #[test] fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() { let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef"); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 828ec69f01..31dcc667d1 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -440,12 +440,18 @@ "auth_elicitation": { "type": "boolean" }, + "auto_compaction": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, "browser_use_external": { "type": "boolean" }, + "browser_use_full_cdp_access": { + "type": "boolean" + }, "chronicle": { "type": "boolean" }, @@ -669,7 +675,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" @@ -967,6 +973,16 @@ } ] }, + "FeatureToml_for_TokenBudgetConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/TokenBudgetConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -1308,6 +1324,9 @@ ], "description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action." }, + "LegacyAppPathString": { + "type": "string" + }, "MarketplaceConfig": { "additionalProperties": false, "properties": { @@ -1712,6 +1731,7 @@ "type": "string" }, "usage_hint_enabled": { + "description": "Deprecated compatibility field. Its value is ignored.", "type": "boolean" }, "usage_hint_text": { @@ -2423,8 +2443,12 @@ "type": "string" }, "cwd": { - "default": null, - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ], + "default": null }, "default_tools_approval_mode": { "allOf": [ @@ -2660,10 +2684,13 @@ "minimum": 0.0, "type": "number" }, - "reminder_interval_tokens": { - "format": "int64", - "minimum": 1.0, - "type": "integer" + "reminder_at_remaining_tokens": { + "description": "Remaining weighted-token values that trigger reminders when crossed.", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" }, "sampling_token_weight": { "format": "double", @@ -2836,6 +2863,27 @@ } ] }, + "TokenBudgetConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reminder_message_template": { + "description": "Reminder template. `{n_remaining}` is replaced with the tokens remaining before auto-compaction.", + "maxLength": 1000, + "minLength": 1, + "type": "string" + }, + "reminder_threshold_tokens": { + "description": "Number of tokens remaining before auto-compaction when the wrap-up reminder is emitted.", + "format": "int64", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, "ToolSuggestConfig": { "additionalProperties": false, "properties": { @@ -4703,12 +4751,18 @@ "auth_elicitation": { "type": "boolean" }, + "auto_compaction": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, "browser_use_external": { "type": "boolean" }, + "browser_use_full_cdp_access": { + "type": "boolean" + }, "chronicle": { "type": "boolean" }, @@ -4932,7 +4986,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 3f746ef790..e208b418d3 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -41,7 +41,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: _ => false, }, RolloutItem::ResponseItem( - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -142,7 +143,7 @@ impl AgentControl { .items; let initial_history = InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history, + history: Arc::new(history), rollout_path: stored_thread.rollout_path, }); if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) { @@ -498,7 +499,6 @@ impl AgentControl { } if preserve_reference_context_item && multi_agent_version == MultiAgentVersion::V2 - && config.multi_agent_v2.usage_hint_enabled && let Some(subagent_usage_hint_text) = config.multi_agent_v2.subagent_usage_hint_text.clone() && let Some(subagent_usage_hint_message) = @@ -626,7 +626,7 @@ impl AgentControl { .items; let initial_history = InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history, + history: Arc::new(history), rollout_path: stored_thread.rollout_path, }); let parent_thread_id = stored_thread.parent_thread_id; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 66b5b5c4cb..e37e9b04db 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -72,7 +72,7 @@ fn assistant_message(text: &str, phase: Option) -> ResponseItem { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -92,7 +92,7 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem { namespace: None, arguments: "{}".to_string(), call_id: call_id.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -850,10 +850,7 @@ async fn spawn_thread_subagent_uses_supplied_initial_multi_agent_mode_without_hi .config_snapshot() .await; - assert_eq!( - child_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(child_snapshot.multi_agent_mode, MultiAgentMode::Proactive); } #[tokio::test] @@ -890,6 +887,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; + let expected_parent_seed = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .first() + .cloned() + .expect("parent seed should be recorded"); let turn_context = parent_thread.codex.session.new_default_turn().await; let parent_spawn_call_id = "spawn-call-history".to_string(); let trigger_message = InterAgentCommunication::new( @@ -912,7 +918,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -921,7 +927,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Parent subagent guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), @@ -931,7 +937,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { summary: Vec::new(), content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, trigger_message.to_response_input_item().into(), spawn_agent_call(&parent_spawn_call_id), @@ -987,21 +993,16 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("child thread should be registered"); assert_eq!( child_thread.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; + let mut expected_final_answer = + assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)); + expected_final_answer.set_turn_id_if_missing(&turn_context.sub_id); let expected_history = [ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "parent seed context".to_string(), - }], - phase: None, - metadata: None, - }, - assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + expected_parent_seed, + expected_final_answer, ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1009,7 +1010,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Child subagent guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!( @@ -1025,18 +1026,13 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "full-history forked child should preserve the parent diff baseline" ); - let mut disabled_hint_child_config = harness.config.clone(); - let _ = disabled_hint_child_config - .features - .enable(Feature::MultiAgentV2); - disabled_hint_child_config.multi_agent_v2.usage_hint_enabled = false; - disabled_hint_child_config - .multi_agent_v2 - .subagent_usage_hint_text = Some("Disabled child subagent guidance.".to_string()); - let disabled_hint_child_thread_id = harness + let mut no_hint_child_config = harness.config.clone(); + let _ = no_hint_child_config.features.enable(Feature::MultiAgentV2); + no_hint_child_config.multi_agent_v2.subagent_usage_hint_text = None; + let no_hint_child_thread_id = harness .control .spawn_agent_with_metadata( - disabled_hint_child_config, + no_hint_child_config, text_input("child task without hints"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -1052,24 +1048,17 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }, ) .await - .expect("forked spawn should honor disabled usage hints") + .expect("forked spawn should honor an empty subagent usage hint") .thread_id; - let disabled_hint_child_thread = harness + let no_hint_child_thread = harness .manager - .get_thread(disabled_hint_child_thread_id) + .get_thread(no_hint_child_thread_id) .await - .expect("disabled-hint child thread should be registered"); - let disabled_hint_history = disabled_hint_child_thread - .codex - .session - .clone_history() - .await; + .expect("no-hint child thread should be registered"); + let no_hint_history = no_hint_child_thread.codex.session.clone_history().await; assert!( - !history_contains_text( - disabled_hint_history.raw_items(), - "Disabled child subagent guidance.", - ), - "full-history forked child should not add subagent guidance when usage hints are disabled" + !history_contains_text(no_hint_history.raw_items(), "Child subagent guidance."), + "full-history forked child should not add empty subagent guidance" ); let expected = ( @@ -1099,9 +1088,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("child shutdown should submit"); let _ = harness .control - .shutdown_live_agent(disabled_hint_child_thread_id) + .shutdown_live_agent(no_hint_child_thread_id) .await - .expect("disabled-hint child shutdown should submit"); + .expect("no-hint child shutdown should submit"); let _ = parent_thread .submit(Op::Shutdown {}) .await @@ -1140,7 +1129,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { text: "compacted parent summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1149,7 +1138,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; parent_thread @@ -1160,6 +1149,8 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { message: String::new(), replacement_history: Some(replacement_history), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::TurnContext(turn_context.to_turn_context_item()), @@ -1404,7 +1395,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .expect("child thread should be registered"); assert_eq!( child_thread.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); let history = child_thread.codex.session.clone_history().await; @@ -1462,7 +1453,7 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li text: "parent startup developer context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -1584,7 +1575,7 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, spawn_agent_call(&parent_spawn_call_id), ], @@ -2420,10 +2411,7 @@ async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_age assert_eq!(resumed_agent_path, Some(agent_path)); assert_eq!(resumed_nickname, Some(original_nickname)); assert_eq!(resumed_role, Some("explorer".to_string())); - assert_eq!( - resumed_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(resumed_snapshot.multi_agent_mode, MultiAgentMode::Proactive); let _ = harness .control diff --git a/codex-rs/core/src/apply_patch_tests.rs b/codex-rs/core/src/apply_patch_tests.rs index dfc48f8a63..d845ca92b4 100644 --- a/codex-rs/core/src/apply_patch_tests.rs +++ b/codex-rs/core/src/apply_patch_tests.rs @@ -8,7 +8,7 @@ use tempfile::tempdir; fn convert_apply_patch_maps_add_variant() { let tmp = tempdir().expect("tmp"); let path = tmp.path().join("a.txt"); - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let action = ApplyPatchAction::new_add_for_test(&path_uri, "hello".to_string()); let got = convert_apply_patch_to_protocol(&action); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 73d7398a3d..9e240e968d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -73,6 +73,7 @@ use codex_otel::current_span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; +use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -112,7 +113,6 @@ use crate::feedback_tags; use crate::responses_metadata::CodexResponsesMetadata; use crate::responses_metadata::subagent_header_value; use crate::util::emit_feedback_auth_recovery_tags; -use codex_api::map_api_error; use codex_feedback::FeedbackRequestTags; use codex_feedback::emit_feedback_request_tags_with_auth_env; use codex_login::auth_env_telemetry::AuthEnvTelemetry; @@ -163,6 +163,19 @@ pub(crate) struct CompactConversationRequestSettings { pub(crate) service_tier: Option, } +fn session_telemetry_for_request( + session_telemetry: &SessionTelemetry, + request: &ResponsesApiRequest, +) -> SessionTelemetry { + session_telemetry.clone().with_inference_request( + request.service_tier.as_deref(), + request + .reasoning + .as_ref() + .and_then(|reasoning| reasoning.effort.as_ref()), + ) +} + /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most @@ -577,7 +590,7 @@ impl ModelClient { turn_state.as_deref(), ) .await - .map_err(map_api_error); + .map_err(|error| self.state.provider.map_api_error(error)); trace_attempt.record_result(result.as_deref()); result } @@ -604,7 +617,7 @@ impl ModelClient { let response = ApiRealtimeCallClient::new(transport, api_provider, client_setup.api_auth) .create_with_session_and_headers(sdp, session_config, extra_headers) .await - .map_err(map_api_error)?; + .map_err(|error| self.state.provider.map_api_error(error))?; Ok(RealtimeWebrtcCallStart { sdp: response.sdp, call_id: response.call_id, @@ -658,7 +671,7 @@ impl ModelClient { client .summarize_input(&payload, self.build_subagent_headers()) .await - .map_err(map_api_error) + .map_err(|error| self.state.provider.map_api_error(error)) } fn build_subagent_headers(&self) -> ApiHeaderMap { @@ -778,12 +791,35 @@ impl ModelClient { service_tier: Option, responses_metadata: &CodexResponsesMetadata, ) -> Result { - let instructions = &prompt.base_instructions.text; let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); if !self.state.provider.info().is_openai() { - input.iter_mut().for_each(ResponseItem::clear_metadata); + input + .iter_mut() + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let tools = create_tools_json_for_responses_api(&prompt.tools)?; + let (instructions, tools) = if model_info.use_responses_lite { + let mut prefix = vec![ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools, + }]; + if !prompt.base_instructions.text.is_empty() { + prefix.push(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: prompt.base_instructions.text.clone(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }); + } + input.splice(0..0, prefix); + (String::new(), None) + } else { + (prompt.base_instructions.text.clone(), Some(tools)) + }; let reasoning = Self::build_reasoning(model_info, effort, summary); let include = if reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] @@ -810,7 +846,7 @@ impl ModelClient { let service_tier = model_info.service_tier_for_request(service_tier); let request = ResponsesApiRequest { model: model_info.slug.clone(), - instructions: instructions.clone(), + instructions, input, tools, tool_choice: "auto".to_string(), @@ -1072,7 +1108,7 @@ impl ModelClientSession { if !self.client.state.provider.info().is_openai() { response_items .iter_mut() - .for_each(ResponseItem::clear_metadata); + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let Some(incremental_items) = after_previous_input.strip_prefix(response_items.as_slice()) else { @@ -1318,6 +1354,8 @@ impl ModelClientSession { let store = request.store; self.client .prepare_response_items_for_request(&mut request.input, store); + let request_session_telemetry = + session_telemetry_for_request(session_telemetry, &request); let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.add_request_headers(&mut options.extra_headers); inference_trace_attempt.record_started(&request); @@ -1333,8 +1371,9 @@ impl ModelClientSession { Ok(stream) => { let (stream, _) = map_response_stream( stream, - session_telemetry.clone(), + request_session_telemetry, inference_trace_attempt, + Arc::clone(&self.client.state.provider), ); return Ok(stream); } @@ -1353,6 +1392,7 @@ impl ModelClientSession { unauthorized_transport, &mut auth_recovery, session_telemetry, + &self.client.state.provider, ) .await?, ); @@ -1361,7 +1401,7 @@ impl ModelClientSession { Err(err) => { let response_debug_context = extract_response_debug_context_from_api_error(&err); - let err = map_api_error(err); + let err = self.client.state.provider.map_api_error(err); inference_trace_attempt.record_failed( &err, response_debug_context.request_id.as_deref(), @@ -1423,6 +1463,12 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; + let request_session_telemetry = if warmup { + // `generate=false` prewarm is connection setup, not an inference request. + session_telemetry.clone() + } else { + session_telemetry_for_request(session_telemetry, &request) + }; let mut client_metadata = self .client .build_ws_client_metadata(responses_metadata, model_info.use_responses_lite); @@ -1467,12 +1513,13 @@ impl ModelClientSession { unauthorized_transport, &mut auth_recovery, session_telemetry, + &self.client.state.provider, ) .await?, ); continue; } - Err(err) => return Err(map_api_error(err)), + Err(err) => return Err(self.client.state.provider.map_api_error(err)), } let (mut ws_request, previous_response_id_from_untraced_warmup) = @@ -1501,7 +1548,7 @@ impl ModelClientSession { self.websocket_session.last_response_from_untraced_warmup = warmup; let websocket_connection = self.websocket_session.connection.as_ref().ok_or_else(|| { - map_api_error(ApiError::Stream( + self.client.state.provider.map_api_error(ApiError::Stream( "websocket connection is unavailable".to_string(), )) })?; @@ -1515,7 +1562,7 @@ impl ModelClientSession { .map_err(|err| { let response_debug_context = extract_response_debug_context_from_api_error(&err); - let err = map_api_error(err); + let err = self.client.state.provider.map_api_error(err); inference_trace_attempt.record_failed( &err, response_debug_context.request_id.as_deref(), @@ -1525,8 +1572,9 @@ impl ModelClientSession { })?; let (stream, last_request_rx) = map_response_stream( stream_result, - session_telemetry.clone(), + request_session_telemetry, inference_trace_attempt, + Arc::clone(&self.client.state.provider), ); self.websocket_session.last_response_rx = Some(last_request_rx); return Ok(WebsocketStreamOutcome::Stream(stream)); @@ -1759,6 +1807,7 @@ fn map_response_stream( api_stream: codex_api::ResponseStream, session_telemetry: SessionTelemetry, inference_trace_attempt: InferenceTraceAttempt, + provider: SharedModelProvider, ) -> (ResponseStream, oneshot::Receiver) { let codex_api::ResponseStream { rx_event, @@ -1773,6 +1822,7 @@ fn map_response_stream( api_stream, session_telemetry, inference_trace_attempt, + provider, ) } @@ -1781,6 +1831,7 @@ fn map_response_events( api_stream: S, session_telemetry: SessionTelemetry, inference_trace_attempt: InferenceTraceAttempt, + provider: SharedModelProvider, ) -> (ResponseStream, oneshot::Receiver) where S: futures::Stream> @@ -1891,7 +1942,7 @@ where if let Some(upstream_request_id) = upstream_request_id { feedback_tags!(last_model_request_id = upstream_request_id); } - let mapped = map_api_error(err); + let mapped = provider.map_api_error(err); inference_trace_attempt.record_failed( &mapped, upstream_request_id, @@ -1997,6 +2048,7 @@ async fn handle_unauthorized( transport: TransportError, auth_recovery: &mut Option, session_telemetry: &SessionTelemetry, + provider: &SharedModelProvider, ) -> Result { let debug = extract_response_debug_context(&transport); if let Some(recovery) = auth_recovery @@ -2106,7 +2158,7 @@ async fn handle_unauthorized( debug.auth_error_code.as_deref(), ); - Err(map_api_error(ApiError::Transport(transport))) + Err(provider.map_api_error(ApiError::Transport(transport))) } fn api_error_http_status(error: &ApiError) -> Option { diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e0c2ff03c3..54ad70daaa 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -83,7 +83,8 @@ fn strip_image_details(items: &mut [ResponseItem]) { } } } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 8107049ab3..38c4aaecb5 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -20,7 +20,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Original), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -31,7 +31,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::High), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -43,7 +43,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Auto), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ..Default::default() @@ -68,7 +68,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -79,7 +79,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -91,7 +91,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -110,7 +110,7 @@ fn serializes_text_verbosity_when_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -157,7 +157,7 @@ fn serializes_text_schema_with_strict_format() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -218,7 +218,7 @@ fn omits_text_when_not_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -241,7 +241,7 @@ fn serializes_flex_service_tier_when_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input: vec![], - tools: vec![], + tools: Some(vec![]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 1d0c4c48c2..9df7d4d01d 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -15,10 +15,13 @@ use crate::test_support::TestCodexResponsesRequestKind; use crate::test_support::responses_metadata as test_responses_metadata; use codex_api::ApiError; use codex_api::ResponseEvent; +use codex_api::TransportError; use codex_app_server_protocol::AuthMode; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider::BearerAuthProvider; +use codex_model_provider::SharedModelProvider; +use codex_model_provider::create_model_provider; use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; @@ -81,6 +84,10 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { ) } +fn test_model_provider() -> SharedModelProvider { + test_model_client(SessionSource::Cli).state.provider.clone() +} + fn test_responses_metadata_for_client( client: &ModelClient, turn_id: Option<&str>, @@ -226,7 +233,7 @@ fn output_message(id: &str, text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -391,6 +398,7 @@ async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Re api_stream, test_session_telemetry(), attempt, + test_model_provider(), ); let observed = stream @@ -440,6 +448,7 @@ async fn response_stream_records_last_model_feedback_ids() { api_stream, test_session_telemetry(), InferenceTraceAttempt::disabled(), + test_model_provider(), ); while stream.next().await.is_some() {} @@ -455,6 +464,39 @@ async fn response_stream_records_last_model_feedback_ids() { ); } +#[tokio::test] +async fn bedrock_unauthorized_error_uses_provider_mapping() { + let provider = create_model_provider( + ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), + /*auth_manager*/ None, + ); + let mut auth_recovery = None; + let url = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses"; + let error = super::handle_unauthorized( + TransportError::Http { + status: http::StatusCode::UNAUTHORIZED, + url: Some(url.to_string()), + headers: None, + body: Some( + "Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z" + .to_string(), + ), + }, + &mut auth_recovery, + &test_session_telemetry(), + &provider, + ) + .await + .expect_err("expired Bedrock signature should fail"); + + assert_eq!( + error.to_string(), + format!( + "Amazon Bedrock rejected the request because its AWS signature has expired. Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or unset it, then restart Codex, url: {url}" + ) + ); +} + #[tokio::test] async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> { @@ -481,6 +523,7 @@ async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() api_stream, test_session_telemetry(), attempt, + test_model_provider(), ); // Fill the mapper channel with non-terminal events, then yield one output diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 21a86f224e..6d51a0032b 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -81,7 +81,7 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { call_id: "call-1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, }), }) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 0f63323d48..a522cd62aa 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -72,7 +72,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_summary: Option, pub personality: Option, pub collaboration_mode: CollaborationMode, - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, pub session_source: SessionSource, pub forked_from_thread_id: Option, pub parent_thread_id: Option, @@ -452,7 +452,7 @@ impl CodexThread { role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; self.codex .session diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7a2eaf935f..c60bd155e8 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -30,9 +30,9 @@ use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; @@ -302,7 +302,12 @@ async fn run_compact_task_inner_impl( let user_messages = collect_user_messages(history_items); let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); - let (window_number, window_id) = sess.advance_auto_compact_window().await; + if let Some(summary_item) = new_history.last_mut() { + // This replacement history skips `record_conversation_items`; only the appended summary + // belongs to this compaction turn. + summary_item.set_turn_id_if_missing(&turn_context.sub_id); + } + let (window_number, window_ids) = sess.advance_auto_compact_window().await; if matches!( initial_context_injection, @@ -320,7 +325,9 @@ async fn run_compact_task_inner_impl( message: summary_text.clone(), replacement_history: Some(new_history.clone()), window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), }; sess.replace_compacted_history( turn_context.as_ref(), @@ -457,7 +464,7 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option { #[derive(Clone, Debug, PartialEq)] pub(crate) struct CompactedUserMessage { message: String, - metadata: Option, + internal_chat_message_metadata_passthrough: Option, } pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { @@ -470,8 +477,11 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec metadata.clone(), + internal_chat_message_metadata_passthrough: match item { + ResponseItem::Message { + internal_chat_message_metadata_passthrough, + .. + } => internal_chat_message_metadata_passthrough.clone(), _ => None, }, }) @@ -578,7 +588,9 @@ fn build_compacted_history_with_limit( truncate_text(&message.message, TruncationPolicy::Tokens(remaining)); selected_messages.push(CompactedUserMessage { message: truncated, - metadata: message.metadata.clone(), + internal_chat_message_metadata_passthrough: message + .internal_chat_message_metadata_passthrough + .clone(), }); break; } @@ -594,7 +606,9 @@ fn build_compacted_history_with_limit( text: message.message.clone(), }], phase: None, - metadata: message.metadata.clone(), + internal_chat_message_metadata_passthrough: message + .internal_chat_message_metadata_passthrough + .clone(), }); } @@ -609,7 +623,7 @@ fn build_compacted_history_with_limit( role: "user".to_string(), content: vec![ContentItem::InputText { text: summary_text }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); history diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 26469c201a..48784141a8 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -258,7 +258,7 @@ async fn run_remote_compact_task_inner_impl( &responses_metadata, ) .await?; - let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; + let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -275,7 +275,9 @@ async fn run_remote_compact_task_inner_impl( message: String::new(), replacement_history: Some(new_history.clone()), window_number: Some(new_window_number), - window_id: Some(new_window_id), + first_window_id: Some(new_window_ids.first_window_id.to_string()), + previous_window_id: new_window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(new_window_ids.window_id.to_string()), }; // Install is the semantic boundary where the compact endpoint's output becomes live // thread history. Keep it distinct from the later inference request so the reducer can @@ -350,7 +352,8 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -412,31 +415,31 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), output: truncated_output_payload(output), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::CustomToolCallOutput { id, call_id, name, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::CustomToolCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncated_output_payload(output), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::ToolSearchOutput { call_id, status, execution, - metadata, + internal_chat_message_metadata_passthrough: metadata, .. } => ResponseItem::ToolSearchOutput { id: item.id().map(str::to_string), @@ -444,7 +447,7 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option return None, }) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 6f10a474a1..d188f9dfc1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -165,18 +165,18 @@ async fn run_remote_compact_task_inner( attempt .track(sess.as_ref(), status, codex_error, analytics_details) .await; - if matches!(&result, Err(CodexErr::TurnAborted)) { - return result; + match result { + Ok(()) => Ok(()), + Err(err @ CodexErr::TurnAborted) => Err(err), + Err(err) => { + sess.track_turn_codex_error(turn_context, &err); + let event = EventMsg::Error( + err.to_error_event(Some("Error running remote compact task".to_string())), + ); + sess.send_event(turn_context, event).await; + Err(err) + } } - if let Err(err) = result { - sess.track_turn_codex_error(turn_context, &err); - let event = EventMsg::Error( - err.to_error_event(Some("Error running remote compact task".to_string())), - ); - sess.send_event(turn_context, event).await; - return Err(err); - } - Ok(()) } async fn run_remote_compact_task_inner_impl( @@ -234,7 +234,7 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { metadata: None }); + input.push(ResponseItem::CompactionTrigger {}); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -292,7 +292,7 @@ async fn run_remote_compact_task_inner_impl( let (compacted_history, retained_images) = build_v2_compacted_history(&prompt_input, compaction_output); analytics_details.retained_image_count = Some(retained_images); - let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; + let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -309,7 +309,9 @@ async fn run_remote_compact_task_inner_impl( message: String::new(), replacement_history: Some(new_history.clone()), window_number: Some(new_window_number), - window_id: Some(new_window_id), + first_window_id: Some(new_window_ids.first_window_id.to_string()), + previous_window_id: new_window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(new_window_ids.window_id.to_string()), }; compaction_trace.record_installed(&CompactionCheckpointTracePayload { input_history: &trace_input_history, @@ -524,7 +526,7 @@ fn truncate_message_text_to_token_budget( role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } = item else { return Some(item); @@ -563,7 +565,7 @@ fn truncate_message_text_to_token_budget( role, content: truncated_content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, }) } @@ -584,7 +586,7 @@ mod tests { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -616,18 +618,18 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call_1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "old".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (history, _) = build_v2_compacted_history(&input, output.clone()); @@ -656,7 +658,7 @@ mod tests { let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (history, _) = build_v2_compacted_history(&input, output.clone()); @@ -683,12 +685,12 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (_, retained_image_count) = build_v2_compacted_history(&input, output); @@ -736,7 +738,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let truncated = @@ -760,7 +762,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -775,7 +777,7 @@ mod tests { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let newest = message("user", "new", /*phase*/ None); let retained = vec![ @@ -800,7 +802,7 @@ mod tests { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let newest = message("user", "new", /*phase*/ None); let retained = vec![image_only_message, newest.clone()]; @@ -816,7 +818,7 @@ mod tests { let compaction = ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let stream = response_stream(vec![ Ok(ResponseEvent::OutputItemDone(message( diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index a09a6dbb3e..bb6adf643d 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -2,7 +2,7 @@ use super::*; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; -use codex_protocol::models::ResponseItemMetadata; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use pretty_assertions::assert_eq; async fn process_compacted_history_with_test_session( @@ -32,14 +32,14 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn compacted_user_message(text: &str) -> CompactedUserMessage { CompactedUserMessage { message: text.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -84,7 +84,7 @@ fn collect_user_messages_extracts_user_text_only() { text: "ignored".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: Some("user".to_string()), @@ -93,7 +93,7 @@ fn collect_user_messages_extracts_user_text_only() { text: "first".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Other, ]; @@ -118,7 +118,7 @@ do things .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -127,7 +127,7 @@ do things text: "cwd=/tmp".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -136,7 +136,7 @@ do things text: "real user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -231,15 +231,16 @@ fn build_token_limited_compacted_history_appends_summary_message() { } #[test] -fn build_compacted_history_preserves_user_message_metadata() { +fn build_compacted_history_preserves_user_message_passthrough_metadata() { let history = build_compacted_history( Vec::new(), &[CompactedUserMessage { message: "first user message".to_string(), - metadata: Some(ResponseItemMetadata { - turn_id: Some("turn-1".to_string()), - ..Default::default() - }), + internal_chat_message_metadata_passthrough: Some( + InternalChatMessageMetadataPassthrough { + turn_id: Some("turn-1".to_string()), + }, + ), }], "summary text", ); @@ -282,7 +283,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "stale permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -291,7 +292,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -300,7 +301,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "stale personality".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let (refreshed, mut expected) = process_compacted_history_with_test_session( @@ -315,7 +316,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -329,7 +330,7 @@ async fn process_compacted_history_reinjects_full_initial_context() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let (refreshed, mut expected) = process_compacted_history_with_test_session( compacted_history, @@ -343,7 +344,7 @@ async fn process_compacted_history_reinjects_full_initial_context() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -363,7 +364,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -376,7 +377,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -389,7 +390,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -398,7 +399,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -407,7 +408,7 @@ keep me updated text: "stale developer instructions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let (refreshed, mut expected) = process_compacted_history_with_test_session( @@ -422,7 +423,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -462,7 +463,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -471,7 +472,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -480,7 +481,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -497,7 +498,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -506,7 +507,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; expected.extend(initial_context); @@ -517,7 +518,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -531,7 +532,7 @@ async fn process_compacted_history_reinjects_model_switch_message() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let previous_turn_settings = PreviousTurnSettings { model: "previous-regular-model".to_string(), @@ -562,7 +563,7 @@ async fn process_compacted_history_reinjects_model_switch_message() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -577,7 +578,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -586,7 +587,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -595,7 +596,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let initial_context = vec![ResponseItem::Message { @@ -605,7 +606,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let refreshed = @@ -618,7 +619,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -627,7 +628,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -636,7 +637,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -645,7 +646,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(refreshed, expected); @@ -656,7 +657,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let initial_context = vec![ResponseItem::Message { id: None, @@ -665,7 +666,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let refreshed = @@ -678,12 +679,12 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(refreshed, expected); diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index c01c555519..1a9245b1aa 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1,6 +1,8 @@ use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConstraintError; +use crate::config::PermissionProfileCatalogEntry; +use crate::config::permission_profile_catalog; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; use codex_config::CloudConfigBundleLoadError; @@ -1733,6 +1735,74 @@ managed-standard = true Ok(()) } +#[tokio::test] +async fn permission_profile_catalog_marks_profiles_disallowed_by_requirements() -> anyhow::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_sandbox_modes = ["read-only", "workspace-write"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard] +extends = ":workspace" + +[permissions.managed-disabled] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + permission_profile_catalog(&config.config_layer_stack)?, + vec![ + PermissionProfileCatalogEntry { + id: ":read-only".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: ":workspace".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: ":danger-full-access".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: "managed-disabled".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: "managed-standard".to_string(), + description: None, + allowed: true, + }, + ] + ); + Ok(()) +} + #[tokio::test] async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 904018ebc6..67cb0932de 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -89,6 +89,7 @@ use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::LegacyAppPathString; use serde::Deserialize; use tempfile::tempdir; @@ -460,6 +461,90 @@ direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] Ok(()) } +#[tokio::test] +async fn load_config_resolves_token_budget_config() -> std::io::Result<()> { + for (config_toml, expected) in [ + ( + "[features]\ntoken_budget = true\n", + TokenBudgetConfig::default(), + ), + ( + r#" +[features.token_budget] +enabled = true +reminder_threshold_tokens = 16000 +reminder_message_template = "Custom reminder: {n_remaining} tokens." +"#, + TokenBudgetConfig { + reminder_threshold_tokens: Some(16_000), + reminder_message_template: "Custom reminder: {n_remaining} tokens.".to_string(), + }, + ), + ] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::TokenBudget)); + assert_eq!(config.token_budget, Some(expected)); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_invalid_token_budget_reminder_template() -> std::io::Result<()> { + for reminder_message_template in [ + String::new(), + "x".repeat(TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES + 1), + ] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(&format!( + "[features.token_budget]\nenabled = true\nreminder_message_template = {reminder_message_template:?}\n" + )) + .expect("TOML should deserialize"); + let error = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("invalid reminder template should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_non_positive_token_budget_reminder_threshold() -> std::io::Result<()> { + for reminder_threshold_tokens in [-1, 0] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(&format!( + "[features.token_budget]\nenabled = true\nreminder_threshold_tokens = {reminder_threshold_tokens}\n" + )) + .expect("TOML should deserialize"); + let error = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("non-positive reminder threshold should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "features.token_budget.reminder_threshold_tokens must be positive" + ); + } + Ok(()) +} + #[tokio::test] async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { let codex_home = tempdir()?; @@ -468,7 +553,7 @@ async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { [features.rollout_budget] enabled = true limit_tokens = 100000 -reminder_interval_tokens = 10000 +reminder_at_remaining_tokens = [50000, 25000, 10000] sampling_token_weight = 1.0 prefill_token_weight = 0.1 "#, @@ -487,7 +572,7 @@ prefill_token_weight = 0.1 config.rollout_budget, Some(RolloutBudgetConfig { limit_tokens: 100_000, - reminder_interval_tokens: 10_000, + reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, prefill_token_weight: 0.1, }) @@ -3108,9 +3193,10 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( config.custom_permission_profiles, - vec![CustomPermissionProfileSummary { + vec![PermissionProfileCatalogEntry { id: "dev".to_string(), description: Some("Workspace access.".to_string()), + allowed: true, }] ); assert!( @@ -5469,6 +5555,7 @@ async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<() #[tokio::test] async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { let codex_home = TempDir::new()?; + let expected_cwd = LegacyAppPathString::from_path(codex_home.path()); let mut servers = BTreeMap::new(); servers.insert( @@ -5479,7 +5566,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { args: vec!["hello".to_string()], env: None, env_vars: Vec::new(), - cwd: Some(codex_home.path().to_path_buf()), + cwd: Some(expected_cwd.clone()), }, environment_id: "remote".to_string(), enabled: true, @@ -5518,7 +5605,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); assert!(env_vars.is_empty()); - assert_eq!(cwd, &Some(codex_home.path().to_path_buf())); + assert_eq!(cwd, &Some(expected_cwd)); } other => panic!("unexpected transport {other:?}"), } @@ -6019,6 +6106,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let cwd_path = PathBuf::from("/tmp/codex-mcp"); + let cwd = LegacyAppPathString::from_path(&cwd_path); let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { @@ -6027,7 +6115,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { args: Vec::new(), env: None, env_vars: Vec::new(), - cwd: Some(cwd_path.clone()), + cwd: Some(cwd.clone()), }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, @@ -6062,7 +6150,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { cwd, .. } => { - assert_eq!(cwd.as_deref(), Some(Path::new("/tmp/codex-mcp"))); + assert_eq!(cwd, &Some(LegacyAppPathString::from_path(&cwd_path))); } other => panic!("unexpected transport {other:?}"), } @@ -9667,6 +9755,7 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { [features] in_app_browser = false browser_use = false +browser_use_full_cdp_access = false "#, ), ) @@ -9675,6 +9764,7 @@ browser_use = false assert!(!config.features.enabled(Feature::InAppBrowser)); assert!(!config.features.enabled(Feature::BrowserUse)); + assert!(!config.features.enabled(Feature::BrowserUseFullCdpAccess)); Ok(()) } @@ -10030,7 +10120,6 @@ max_concurrent_threads_per_session = 5 min_wait_timeout_ms = 2500 max_wait_timeout_ms = 120000 default_wait_timeout_ms = 30000 -usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." @@ -10058,7 +10147,6 @@ non_code_mode_only = true ), (None, Some(4)) ); - assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), Some("Custom delegation guidance.") diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 5b99306eb9..3aed9a49ba 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -69,7 +69,7 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { entry["env_vars"] = array_from_env_vars(env_vars); } if let Some(cwd) = cwd { - entry["cwd"] = value(cwd.to_string_lossy().to_string()); + entry["cwd"] = value(cwd.as_str()); } } McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8e9ccc6c24..81d2870c11 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -68,9 +68,11 @@ use codex_features::Features; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; +use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; +use codex_login::AuthRouteConfig; use codex_mcp::McpConfig; use codex_mcp::McpPluginAttribution; use codex_mcp::McpServerRegistration; @@ -146,6 +148,7 @@ pub mod edit; mod managed_features; mod network_proxy_spec; mod otel; +mod permission_profile_catalog; mod permissions; mod resolved_permission_profile; #[cfg(test)] @@ -162,6 +165,11 @@ pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub use permission_profile_catalog::PermissionProfileCatalogEntry; +pub use permission_profile_catalog::permission_profile_catalog; +use permission_profile_catalog::permission_profile_catalog_from_permissions; +use permission_profile_catalog::permission_profile_is_allowed; +use permission_profile_catalog::validate_permission_profile_for_deny_read; pub(crate) use permissions::is_builtin_permission_profile_name; pub(crate) use permissions::reject_unknown_builtin_permission_profile; pub(crate) use permissions::resolve_permission_profile; @@ -238,9 +246,8 @@ Payload: ``` You may also see them addressed as to=/root/..., which indicates your identity is /root/... "#; -const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.spawn_agent` without a configured namespace or `to=functions.agents.spawn_agent` with `tool_namespace = "agents"`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. - -The goal is to correctly solve the problem in as little time as possible. Therefore, if at any point you can parallelize work by delegating tasks to another agent, you should do so to save time. +const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; +const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. All agents share the same directory. In detail: - All agents have access to the same container and filesystem as you. @@ -645,7 +652,7 @@ pub struct Config { pub explicit_permission_profile_mode: bool, /// User-defined permission profiles available from effective config. - pub custom_permission_profiles: Vec, + pub custom_permission_profiles: Vec, /// Configures who approval requests are routed to for review once they have /// been escalated. This does not disable separate safety checks such as @@ -1026,6 +1033,8 @@ pub struct Config { /// Settings specific to the task-path-based multi-agent tool surface. pub multi_agent_v2: MultiAgentV2Config, + /// Context-window token budget configuration, when enabled. + pub token_budget: Option, /// Shared token budget for the root thread and its sub-agents. pub rollout_budget: Option, /// Current-time reminder configuration, when enabled. @@ -1075,10 +1084,31 @@ pub struct CodeModeConfig { pub direct_only_tool_namespaces: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub(crate) const DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE: &str = concat!( + "Your context window is nearly exhausted (only {n_remaining} tokens remaining) and will be automatically reset for you soon. ", + "Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows." +); +const TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES: usize = 1000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TokenBudgetConfig { + pub reminder_threshold_tokens: Option, + pub reminder_message_template: String, +} + +impl Default for TokenBudgetConfig { + fn default() -> Self { + Self { + reminder_threshold_tokens: None, + reminder_message_template: DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct RolloutBudgetConfig { pub limit_tokens: i64, - pub reminder_interval_tokens: i64, + pub reminder_at_remaining_tokens: Vec, pub sampling_token_weight: f64, pub prefill_token_weight: f64, } @@ -1104,7 +1134,6 @@ pub struct MultiAgentV2Config { pub min_wait_timeout_ms: i64, pub max_wait_timeout_ms: i64, pub default_wait_timeout_ms: i64, - pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, @@ -1120,7 +1149,6 @@ impl MultiAgentV2Config { min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS, max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS, default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS, - usage_hint_enabled: true, usage_hint_text: None, root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text( DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, @@ -1130,7 +1158,7 @@ impl MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, )), - tool_namespace: None, + tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, non_code_mode_only: true, } @@ -1181,6 +1209,10 @@ impl AuthManagerConfig for Config { fn chatgpt_base_url(&self) -> String { self.chatgpt_base_url.clone() } + + fn auth_route_config(&self) -> Option { + Config::auth_route_config(self) + } } #[derive(Clone, Default)] @@ -1434,6 +1466,11 @@ impl Config { } } + pub fn auth_route_config(&self) -> Option { + self.respect_system_proxy + .then(AuthRouteConfig::respect_system_proxy) + } + /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { PluginsConfigInput::new( @@ -2120,12 +2157,6 @@ pub struct AgentRoleConfig { pub nickname_candidates: Option>, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CustomPermissionProfileSummary { - pub id: String, - pub description: Option, -} - fn resolve_tool_suggest_config( config_toml: &ConfigToml, config_layer_stack: &ConfigLayerStack, @@ -2468,9 +2499,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config let default_wait_timeout_ms = base .and_then(|config| config.default_wait_timeout_ms) .unwrap_or(default.default_wait_timeout_ms); - let usage_hint_enabled = base - .and_then(|config| config.usage_hint_enabled) - .unwrap_or(default.usage_hint_enabled); let usage_hint_text = base .and_then(|config| config.usage_hint_text.as_ref()) .cloned() @@ -2499,7 +2527,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config min_wait_timeout_ms, max_wait_timeout_ms, default_wait_timeout_ms, - usage_hint_enabled, usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, @@ -2509,6 +2536,48 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config } } +fn resolve_token_budget_config( + config_toml: &ConfigToml, + features: &ManagedFeatures, +) -> std::io::Result> { + if !features.enabled(Feature::TokenBudget) { + return Ok(None); + } + + let token_budget_config = token_budget_toml_config(config_toml.features.as_ref()); + let reminder_threshold_tokens = + token_budget_config.and_then(|config| config.reminder_threshold_tokens); + if reminder_threshold_tokens.is_some_and(|tokens| tokens <= 0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.token_budget.reminder_threshold_tokens must be positive", + )); + } + + let reminder_message_template = token_budget_config + .and_then(|config| config.reminder_message_template.clone()) + .unwrap_or_else(|| DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string()); + if reminder_message_template.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.token_budget.reminder_message_template must not be empty", + )); + } + if reminder_message_template.len() > TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "features.token_budget.reminder_message_template must not exceed {TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES} bytes" + ), + )); + } + + Ok(Some(TokenBudgetConfig { + reminder_threshold_tokens, + reminder_message_template, + })) +} + fn resolve_rollout_budget_config( config_toml: &ConfigToml, features: &ManagedFeatures, @@ -2538,13 +2607,23 @@ fn resolve_rollout_budget_config( "features.rollout_budget.limit_tokens must be positive", )); } - let reminder_interval_tokens = config - .reminder_interval_tokens - .unwrap_or_else(|| (limit_tokens / 10).max(1)); - if reminder_interval_tokens <= 0 { + let reminder_at_remaining_tokens = + config + .reminder_at_remaining_tokens + .clone() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.reminder_at_remaining_tokens is required when rollout_budget is enabled", + ) + })?; + if reminder_at_remaining_tokens + .iter() + .any(|&tokens| tokens <= 0 || tokens >= limit_tokens) + { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "features.rollout_budget.reminder_interval_tokens must be positive", + "features.rollout_budget.reminder_at_remaining_tokens must contain only positive values below limit_tokens", )); } let sampling_token_weight = config.sampling_token_weight.unwrap_or(1.0); @@ -2562,7 +2641,7 @@ fn resolve_rollout_budget_config( } Ok(Some(RolloutBudgetConfig { limit_tokens, - reminder_interval_tokens, + reminder_at_remaining_tokens, sampling_token_weight, prefill_token_weight, })) @@ -2635,6 +2714,13 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA } } +fn token_budget_toml_config(features: Option<&FeaturesToml>) -> Option<&TokenBudgetConfigToml> { + match features?.token_budget.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn current_time_reminder_toml_config( features: Option<&FeaturesToml>, ) -> Option<&CurrentTimeReminderConfigToml> { @@ -2672,6 +2758,15 @@ pub fn resolve_bootstrap_respect_system_proxy( Ok(features.get().enabled(Feature::RespectSystemProxy)) } +/// Resolves auth route settings for the initial cloud-config bootstrap. +pub fn resolve_bootstrap_auth_route_config( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result> { + resolve_bootstrap_respect_system_proxy(cfg, feature_requirements) + .map(|enabled| enabled.then(AuthRouteConfig::respect_system_proxy)) +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, @@ -3038,19 +3133,13 @@ impl Config { permission_config_syntax, Some(PermissionConfigSyntax::Profiles) ); - let custom_permission_profiles = cfg - .permissions - .as_ref() - .map_or_else(Vec::new, |permissions| { - permissions - .entries - .iter() - .map(|(id, profile)| CustomPermissionProfileSummary { - id: id.clone(), - description: profile.description.clone(), - }) - .collect() - }); + let custom_permission_profiles = permission_profile_catalog_from_permissions( + &config_layer_stack, + effective_permission_selection.profiles.as_ref(), + )? + .into_iter() + .filter(|profile| !is_builtin_permission_profile_name(&profile.id)) + .collect(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && effective_permission_selection.selected_profile_id.is_none(); let should_seed_legacy_workspace_roots = effective_permission_selection @@ -3142,7 +3231,6 @@ impl Config { effective_permission_selection.profiles.as_ref(), default_permissions, builtin_workspace_write_settings, - resolved_cwd.as_path(), &mut startup_warnings, )?; let mut configured_workspace_roots = compile_permission_profile_workspace_roots( @@ -3287,6 +3375,7 @@ impl Config { resolve_experimental_request_user_input_enabled(&cfg); let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); + let token_budget = resolve_token_budget_config(&cfg, &features)?; let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?; let current_time_reminder = resolve_current_time_reminder_config(&cfg, &features)?; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); @@ -3562,20 +3651,10 @@ impl Config { constrained_permission_profile .value .add_validator(move |permission_profile| { - let mode = sandbox_mode_requirement_for_permission_profile(permission_profile); - match mode { - SandboxModeRequirement::ReadOnly - | SandboxModeRequirement::WorkspaceWrite => Ok(()), - SandboxModeRequirement::DangerFullAccess - | SandboxModeRequirement::ExternalSandbox => { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: format!("{mode:?}"), - allowed: "[read-only, workspace-write]".to_string(), - requirement_source: requirement_source.clone(), - }) - } - } + validate_permission_profile_for_deny_read( + permission_profile, + &requirement_source, + ) }) .map_err(std::io::Error::from)?; } @@ -3830,6 +3909,7 @@ impl Config { background_terminal_max_timeout, ghost_snapshot, multi_agent_v2, + token_budget, rollout_budget, current_time_reminder, features, @@ -4027,6 +4107,15 @@ impl Config { pub fn bundled_skills_enabled(&self) -> bool { crate::skills::service::bundled_skills_enabled_from_stack(&self.config_layer_stack) } + + /// Returns whether effective requirements allow selecting a concrete profile. + pub fn is_permission_profile_allowed( + &self, + profile_id: &str, + permission_profile: &PermissionProfile, + ) -> bool { + permission_profile_is_allowed(&self.config_layer_stack, profile_id, permission_profile) + } } fn guardian_policy_config_from_requirements( diff --git a/codex-rs/core/src/config/permission_profile_catalog.rs b/codex-rs/core/src/config/permission_profile_catalog.rs new file mode 100644 index 0000000000..51b34c2640 --- /dev/null +++ b/codex-rs/core/src/config/permission_profile_catalog.rs @@ -0,0 +1,140 @@ +use codex_config::ConfigLayerStack; +use codex_config::RequirementSource; +use codex_config::SandboxModeRequirement; +use codex_config::Sourced; +use codex_config::permissions_toml::PermissionsToml; +use codex_config::sandbox_mode_requirement_for_permission_profile; +use codex_protocol::models::PermissionProfile; + +use super::ConstraintError; +use super::ConstraintResult; +use super::is_permission_allowed; +use super::merge_managed_permission_profiles; +use super::permissions::BUILT_IN_DANGER_FULL_ACCESS_PROFILE; +use super::permissions::BUILT_IN_READ_ONLY_PROFILE; +use super::permissions::BUILT_IN_WORKSPACE_PROFILE; +use super::permissions::compile_permission_profile_selection; +use super::permissions::validate_user_permission_profile_names; +use super::validate_required_permission_profile_catalog; + +/// A permission profile exposed to clients together with its effective availability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileCatalogEntry { + pub id: String, + pub description: Option, + pub allowed: bool, +} + +/// Builds the effective permission profile catalog for a config layer stack. +pub fn permission_profile_catalog( + config_layer_stack: &ConfigLayerStack, +) -> std::io::Result> { + let permissions = config_layer_stack + .effective_config() + .get("permissions") + .cloned() + .map(toml::Value::try_into::) + .transpose() + .map_err(std::io::Error::other)?; + let requirements_toml = config_layer_stack.requirements_toml(); + let permissions = merge_managed_permission_profiles(permissions.as_ref(), requirements_toml)?; + + permission_profile_catalog_from_permissions(config_layer_stack, permissions.as_ref()) +} + +pub(super) fn permission_profile_catalog_from_permissions( + config_layer_stack: &ConfigLayerStack, + permissions: Option<&PermissionsToml>, +) -> std::io::Result> { + let requirements_toml = config_layer_stack.requirements_toml(); + validate_user_permission_profile_names(permissions)?; + validate_required_permission_profile_catalog(requirements_toml, permissions)?; + + let mut catalog = [ + (BUILT_IN_READ_ONLY_PROFILE, PermissionProfile::read_only()), + ( + BUILT_IN_WORKSPACE_PROFILE, + PermissionProfile::workspace_write(), + ), + ( + BUILT_IN_DANGER_FULL_ACCESS_PROFILE, + PermissionProfile::Disabled, + ), + ] + .into_iter() + .map(|(id, permission_profile)| PermissionProfileCatalogEntry { + id: id.to_string(), + description: None, + allowed: permission_profile_is_allowed(config_layer_stack, id, &permission_profile), + }) + .collect::>(); + + if let Some(permissions) = permissions { + catalog.extend(permissions.entries.iter().map(|(id, profile)| { + let mut warnings = Vec::new(); + let allowed = compile_permission_profile_selection( + Some(permissions), + id, + /*workspace_write*/ None, + &mut warnings, + ) + .map(|(file_system, network)| { + PermissionProfile::from_runtime_permissions(&file_system, network) + }) + .is_ok_and(|permission_profile| { + permission_profile_is_allowed(config_layer_stack, id, &permission_profile) + }); + PermissionProfileCatalogEntry { + id: id.clone(), + description: profile.description.clone(), + allowed, + } + })); + } + + Ok(catalog) +} + +pub(super) fn permission_profile_is_allowed( + config_layer_stack: &ConfigLayerStack, + profile_id: &str, + permission_profile: &PermissionProfile, +) -> bool { + let allowed_by_id = config_layer_stack + .requirements_toml() + .allowed_permission_profiles + .as_ref() + .is_none_or(|allowed| is_permission_allowed(allowed, profile_id)); + let allowed_by_sandbox_mode = config_layer_stack + .requirements() + .permission_profile + .can_set(permission_profile) + .is_ok(); + let allowed_by_filesystem = config_layer_stack + .requirements() + .filesystem + .as_ref() + .is_none_or(|Sourced { value, source }| { + value.deny_read.is_empty() + || validate_permission_profile_for_deny_read(permission_profile, source).is_ok() + }); + allowed_by_id && allowed_by_sandbox_mode && allowed_by_filesystem +} + +pub(super) fn validate_permission_profile_for_deny_read( + permission_profile: &PermissionProfile, + requirement_source: &RequirementSource, +) -> ConstraintResult<()> { + let mode = sandbox_mode_requirement_for_permission_profile(permission_profile); + match mode { + SandboxModeRequirement::ReadOnly | SandboxModeRequirement::WorkspaceWrite => Ok(()), + SandboxModeRequirement::DangerFullAccess | SandboxModeRequirement::ExternalSandbox => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: requirement_source.clone(), + }) + } + } +} diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index f683d9c7eb..9c6d3ed725 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -346,7 +346,6 @@ pub(crate) fn network_proxy_config_for_profile_selection( pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { let profile = resolve_permission_profile(permissions, profile_name)?; @@ -383,7 +382,6 @@ pub(crate) fn compile_permission_profile( .extend(compile_filesystem_permission( path, permission, - policy_cwd, startup_warnings, )?); } @@ -412,7 +410,6 @@ pub(crate) fn compile_permission_profile_selection( permissions: Option<&PermissionsToml>, profile_name: &str, workspace_write: Option<&SandboxWorkspaceWrite>, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { if let Some(permission_profile) = builtin_permission_profile(profile_name, workspace_write) { @@ -426,7 +423,7 @@ pub(crate) fn compile_permission_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) + compile_permission_profile(permissions, profile_name, startup_warnings) } pub(crate) fn compile_permission_profile_workspace_roots( @@ -524,7 +521,6 @@ fn compile_network_sandbox_policy( fn compile_filesystem_permission( path: &str, permission: &FilesystemPermissionToml, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result> { let mut entries = Vec::new(); @@ -548,9 +544,7 @@ fn compile_filesystem_permission( // exact-path parser so existing path semantics stay intact. let entry = FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { - pattern: compile_scoped_filesystem_pattern( - path, subpath, *access, policy_cwd, - )?, + pattern: compile_scoped_filesystem_pattern(path, subpath, *access)?, }, access: *access, }; @@ -643,7 +637,6 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 88a757181e..2c1f649042 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -543,7 +543,6 @@ fn glob_scan_max_depth_must_be_positive() { #[test] fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> { - let cwd = TempDir::new()?; let mut startup_warnings = Vec::new(); let (file_system_policy, _) = compile_permission_profile( &PermissionsToml { @@ -568,7 +567,6 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> )]), }, "workspace", - cwd.path(), &mut startup_warnings, )?; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 86e5f07f9a..87c3097162 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -40,7 +40,6 @@ use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; use codex_mcp::effective_mcp_servers; -use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::tool_plugin_provenance; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -240,7 +239,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME); - let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -275,7 +273,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), - host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index de735b6f1e..390a20fd41 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -3,7 +3,6 @@ use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use super::AdditionalContextUserFragment; -use super::EnvironmentContext; use super::FragmentRegistration; use super::FragmentRegistrationProxy; use super::InternalModelContextFragment; @@ -16,10 +15,11 @@ use super::SubagentNotification; use super::TurnAborted; use super::UserInstructions; use super::UserShellCommand; +use super::world_state::EnvironmentsState; static USER_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); -static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy = +static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static ADDITIONAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index e93ffb88ff..2f795a8ea3 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,99 +1,13 @@ -use crate::session::turn_context::TurnContext; -use crate::session::turn_context::TurnEnvironment; -use crate::shell::Shell; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; -use codex_protocol::protocol::TurnContextItem; -use codex_protocol::protocol::TurnContextNetworkItem; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::PathUri; use std::collections::HashSet; use std::path::PathBuf; -use super::ContextualUserFragment; - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct EnvironmentContext { - pub(crate) environments: EnvironmentContextEnvironments, - pub(crate) current_date: Option, - pub(crate) timezone: Option, - pub(crate) network: Option, - pub(crate) filesystem: Option, - pub(crate) subagents: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct EnvironmentContextEnvironment { - pub(crate) id: String, - pub(crate) cwd: PathUri, - pub(crate) shell: String, -} - -impl EnvironmentContextEnvironment { - fn legacy(cwd: PathUri, shell: String) -> Self { - Self { - id: String::new(), - cwd, - shell, - } - } - - fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { - environments - .iter() - .map(|environment| Self { - id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), - }) - .collect() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum EnvironmentContextEnvironments { - None, - Single(EnvironmentContextEnvironment), - Multiple(Vec), -} - -impl EnvironmentContextEnvironments { - fn from_vec(environments: Vec) -> Self { - let mut environments = environments; - match environments.pop() { - None => Self::None, - Some(environment) if environments.is_empty() => Self::Single(environment), - Some(environment) => { - environments.push(environment); - Self::Multiple(environments) - } - } - } - - fn equals_except_shell(&self, other: &Self) -> bool { - match (self, other) { - (Self::None, Self::None) => true, - (Self::Single(left), Self::Single(right)) => left.cwd == right.cwd, - (Self::Multiple(left), Self::Multiple(right)) => { - left.len() == right.len() - && left - .iter() - .zip(right.iter()) - .all(|(left, right)| left.id == right.id && left.cwd == right.cwd) - } - _ => false, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FileSystemContext { workspace_roots: Vec, @@ -117,7 +31,7 @@ enum ManagedFileSystemContext { } impl FileSystemContext { - fn from_permission_profile( + pub(super) fn from_permission_profile( permission_profile: &PermissionProfile, workspace_roots: &[AbsolutePathBuf], ) -> Self { @@ -143,7 +57,7 @@ impl FileSystemContext { } } - fn render(&self) -> String { + pub(super) fn render(&self) -> String { let mut rendered = "".to_string(); if !self.workspace_roots.is_empty() { rendered.push_str(""); @@ -283,7 +197,7 @@ fn push_text_element(rendered: &mut String, name: &str, value: &str) { rendered.push_str(&format!("")); } -fn push_xml_escaped_text(rendered: &mut String, value: &str) { +pub(crate) fn push_xml_escaped_text(rendered: &mut String, value: &str) { for ch in value.chars() { match ch { '&' => rendered.push_str("&"), @@ -310,7 +224,7 @@ impl NetworkContext { } } - fn render(&self) -> String { + pub(super) fn render(&self) -> String { let mut rendered = "".to_string(); Self::push_rendered_domain_element(&mut rendered, "allowed", &self.allowed_domains); Self::push_rendered_domain_element(&mut rendered, "denied", &self.denied_domains); @@ -328,258 +242,3 @@ impl NetworkContext { rendered_network.push_str(&format!("")); } } - -impl EnvironmentContext { - pub(crate) fn new( - environments: Vec, - current_date: Option, - timezone: Option, - network: Option, - subagents: Option, - ) -> Self { - Self { - environments: EnvironmentContextEnvironments::from_vec(environments), - current_date, - timezone, - network, - filesystem: None, - subagents, - } - } - - fn new_with_environments( - environments: EnvironmentContextEnvironments, - current_date: Option, - timezone: Option, - network: Option, - filesystem: Option, - subagents: Option, - ) -> Self { - Self { - environments, - current_date, - timezone, - network, - filesystem, - subagents, - } - } - - /// Compares two environment contexts, ignoring the shell. Useful when - /// comparing turn to turn, since the initial environment_context will - /// include the shell, and then it is not configurable from turn to turn. - pub(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { - self.environments.equals_except_shell(&other.environments) - && self.current_date == other.current_date - && self.timezone == other.timezone - && self.network == other.network - && self.filesystem == other.filesystem - && self.subagents == other.subagents - } - - pub(crate) fn diff_from_turn_context_item( - before: &TurnContextItem, - after: &EnvironmentContext, - ) -> Self { - let before_network = Self::network_from_turn_context_item(before); - let before_filesystem = Self::filesystem_from_turn_context_item(before); - let environments = match &after.environments { - EnvironmentContextEnvironments::Single(environment) => { - if PathUri::from_abs_path(&before.cwd) != environment.cwd { - EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( - environment.cwd.clone(), - environment.shell.clone(), - )) - } else { - EnvironmentContextEnvironments::None - } - } - EnvironmentContextEnvironments::Multiple(environments) => { - EnvironmentContextEnvironments::Multiple(environments.clone()) - } - EnvironmentContextEnvironments::None => EnvironmentContextEnvironments::None, - }; - let network = if before_network != after.network { - after.network.clone() - } else { - before_network - }; - let filesystem = if before_filesystem != after.filesystem { - after.filesystem.clone() - } else { - before_filesystem - }; - EnvironmentContext::new_with_environments( - environments, - after.current_date.clone(), - after.timezone.clone(), - network, - filesystem, - /*subagents*/ None, - ) - } - - pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - let mut context = Self::new( - EnvironmentContextEnvironment::from_turn_environments( - &turn_context.environments.turn_environments, - shell, - ), - turn_context.current_date.clone(), - turn_context.timezone.clone(), - Self::network_from_turn_context(turn_context), - /*subagents*/ None, - ); - context.filesystem = Some(FileSystemContext::from_permission_profile( - &turn_context.permission_profile, - &turn_context.config.effective_workspace_roots(), - )); - context - } - - pub(crate) fn from_turn_context_item( - turn_context_item: &TurnContextItem, - shell: String, - ) -> Self { - Self::new_with_environments( - EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( - PathUri::from_abs_path(&turn_context_item.cwd), - shell, - )]), - turn_context_item.current_date.clone(), - turn_context_item.timezone.clone(), - Self::network_from_turn_context_item(turn_context_item), - Self::filesystem_from_turn_context_item(turn_context_item), - /*subagents*/ None, - ) - } - - pub(crate) fn with_subagents(mut self, subagents: String) -> Self { - if !subagents.is_empty() { - self.subagents = Some(subagents); - } - self - } - - fn network_from_turn_context(turn_context: &TurnContext) -> Option { - let network = turn_context - .config - .config_layer_stack - .requirements() - .network - .as_ref()?; - - Some(NetworkContext::new( - network - .domains - .as_ref() - .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) - .unwrap_or_default(), - network - .domains - .as_ref() - .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) - .unwrap_or_default(), - )) - } - - fn network_from_turn_context_item( - turn_context_item: &TurnContextItem, - ) -> Option { - let TurnContextNetworkItem { - allowed_domains, - denied_domains, - } = turn_context_item.network.as_ref()?; - Some(NetworkContext::new( - allowed_domains.clone(), - denied_domains.clone(), - )) - } - - fn filesystem_from_turn_context_item( - turn_context_item: &TurnContextItem, - ) -> Option { - Some(FileSystemContext::from_permission_profile( - &turn_context_item.permission_profile(), - &workspace_roots_from_turn_context_item(turn_context_item), - )) - } -} - -fn workspace_roots_from_turn_context_item( - turn_context_item: &TurnContextItem, -) -> Vec { - if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() { - return workspace_roots.clone(); - } - - vec![turn_context_item.cwd.clone()] -} - -impl ContextualUserFragment for EnvironmentContext { - fn role(&self) -> &'static str { - "user" - } - - fn markers(&self) -> (&'static str, &'static str) { - Self::type_markers() - } - - fn type_markers() -> (&'static str, &'static str) { - ( - codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG, - codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG, - ) - } - - fn body(&self) -> String { - let mut lines = Vec::new(); - match &self.environments { - EnvironmentContextEnvironments::Single(environment) => { - let cwd = environment.cwd.inferred_native_path_string(); - lines.push(format!(" {cwd}")); - lines.push(format!(" {}", environment.shell)); - } - EnvironmentContextEnvironments::Multiple(environments) => { - lines.push(" ".to_string()); - for environment in environments { - lines.push(format!(" ", environment.id)); - let cwd = environment.cwd.inferred_native_path_string(); - lines.push(format!(" {cwd}")); - lines.push(format!(" {}", environment.shell)); - lines.push(" ".to_string()); - } - lines.push(" ".to_string()); - } - EnvironmentContextEnvironments::None => {} - } - if let Some(current_date) = &self.current_date { - lines.push(format!(" {current_date}")); - } - if let Some(timezone) = &self.timezone { - lines.push(format!(" {timezone}")); - } - match &self.network { - Some(network) => { - lines.push(format!(" {}", network.render())); - } - None => { - // TODO(mbolin): Include this line if it helps the model. - // lines.push(" ".to_string()); - } - } - if let Some(filesystem) = &self.filesystem { - lines.push(format!(" {}", filesystem.render())); - } - if let Some(subagents) = &self.subagents { - lines.push(" ".to_string()); - lines.extend(subagents.lines().map(|line| format!(" {line}"))); - lines.push(" ".to_string()); - } - format!("\n{}\n", lines.join("\n")) - } -} - -#[cfg(test)] -#[path = "environment_context_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 3347358bb5..4ba6acf029 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -32,6 +32,7 @@ mod token_budget_context; mod turn_aborted; mod user_instructions; mod user_shell_command; +pub(crate) mod world_state; pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved; pub(crate) use apps_instructions::AppsInstructions; @@ -47,7 +48,6 @@ pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions; pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::parse_visible_hook_prompt_message; pub(crate) use current_time_reminder::CurrentTimeReminder; -pub(crate) use environment_context::EnvironmentContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; @@ -73,6 +73,7 @@ pub(crate) use rollout_budget::RolloutBudgetContext; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use token_budget_context::TokenBudgetContext; pub(crate) use token_budget_context::TokenBudgetRemainingContext; +pub(crate) use token_budget_context::TokenBudgetReminder; pub(crate) use turn_aborted::TurnAborted; pub(crate) use user_instructions::UserInstructions; pub(crate) use user_shell_command::UserShellCommand; diff --git a/codex-rs/core/src/context/multi_agent_mode_instructions.rs b/codex-rs/core/src/context/multi_agent_mode_instructions.rs index a272e7c2bb..fc4debe5a2 100644 --- a/codex-rs/core/src/context/multi_agent_mode_instructions.rs +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -4,6 +4,7 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const NO_MULTI_AGENT_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive. Any earlier multi-agent mode developer message no longer applies."; const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it."; #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,6 +33,7 @@ impl ContextualUserFragment for MultiAgentModeInstructions { fn body(&self) -> String { match self.multi_agent_mode { + MultiAgentMode::None => NO_MULTI_AGENT_MODE_TEXT.to_string(), MultiAgentMode::ExplicitRequestOnly => { EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string() } diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index b21b617355..3b48aeaa72 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -1,20 +1,32 @@ use super::ContextualUserFragment; use codex_protocol::ThreadId; +use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TokenBudgetContext { thread_id: ThreadId, + first_window_id: Uuid, + previous_window_id: Option, window_id: Uuid, - tokens_left: i64, + mcp_result: Option, } impl TokenBudgetContext { - pub(crate) fn new(thread_id: ThreadId, window_id: Uuid, tokens_left: i64) -> Self { + pub(crate) fn new( + thread_id: ThreadId, + first_window_id: Uuid, + previous_window_id: Option, + window_id: Uuid, + mcp_result: Option, + ) -> Self { Self { thread_id, + first_window_id, + previous_window_id, window_id, - tokens_left, + mcp_result, } } } @@ -29,16 +41,25 @@ impl ContextualUserFragment for TokenBudgetContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + (CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG) } fn body(&self) -> String { let thread_id = self.thread_id; + let first_window_id = self.first_window_id; let window_id = self.window_id; - let tokens_left = self.tokens_left; - format!( - "Thread id {thread_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." - ) + let mut lines = vec![ + format!("Thread id: {thread_id}"), + format!("First context window id: {first_window_id}"), + format!("Current context window id: {window_id}"), + ]; + if let Some(previous_window_id) = self.previous_window_id { + lines.push(format!("Previous context window id: {previous_window_id}")); + } + if let Some(mcp_result) = &self.mcp_result { + lines.push(mcp_result.clone()); + } + format!("\n{}\n", lines.join("\n")) } } @@ -69,7 +90,7 @@ impl ContextualUserFragment for TokenBudgetRemainingContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { @@ -81,3 +102,34 @@ impl ContextualUserFragment for TokenBudgetRemainingContext { } } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TokenBudgetReminder { + message: String, +} + +impl TokenBudgetReminder { + pub(crate) fn new(message_template: &str, n_remaining: i64) -> Self { + Self { + message: message_template.replace("{n_remaining}", &n_remaining.to_string()), + } + } +} + +impl ContextualUserFragment for TokenBudgetReminder { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + self.message.clone() + } +} diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs new file mode 100644 index 0000000000..589e8dbadf --- /dev/null +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -0,0 +1,387 @@ +use super::WorldStateSection; +use crate::context::ContextualUserFragment; +use crate::context::environment_context::FileSystemContext; +use crate::context::environment_context::NetworkContext; +use crate::context::environment_context::push_xml_escaped_text; +use crate::environment_selection::TurnEnvironmentSnapshot; +use crate::session::turn_context::TurnContext; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_protocol::protocol::TurnContextItem; +use codex_protocol::protocol::TurnContextNetworkItem; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use std::collections::BTreeMap; + +/// Environment values visible to the model. +#[derive(Clone, Debug, Default)] +pub(crate) struct EnvironmentsState { + environments: BTreeMap, + current_date: Option, + timezone: Option, + network: Option, + filesystem: Option, + subagents: Option, +} + +impl EnvironmentsState { + pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self { + Self::from_turn_context_with_environments(turn_context, &turn_context.environments) + } + + pub(crate) fn from_turn_context_with_environments( + turn_context: &TurnContext, + environments: &TurnEnvironmentSnapshot, + ) -> Self { + Self { + environments: environment_states(environments), + current_date: turn_context.current_date.clone(), + timezone: turn_context.timezone.clone(), + network: network_from_turn_context(turn_context), + filesystem: Some(FileSystemContext::from_permission_profile( + &turn_context.permission_profile, + &turn_context.config.effective_workspace_roots(), + )), + subagents: None, + } + } + + pub(crate) fn from_turn_context_item(turn_context_item: &TurnContextItem) -> Self { + Self { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + EnvironmentState { + cwd: PathUri::from_abs_path(&turn_context_item.cwd), + status: EnvironmentStatus::Available, + shell: None, + }, + )] + .into_iter() + .collect(), + current_date: turn_context_item.current_date.clone(), + timezone: turn_context_item.timezone.clone(), + network: network_from_turn_context_item(turn_context_item), + filesystem: Some(FileSystemContext::from_permission_profile( + &turn_context_item.permission_profile(), + &workspace_roots_from_turn_context_item(turn_context_item), + )), + subagents: None, + } + } + + pub(crate) fn with_subagents(mut self, subagents: String) -> Self { + if !subagents.is_empty() { + self.subagents = Some(subagents); + } + self + } + + fn rendered_full(&self) -> RenderedEnvironments { + RenderedEnvironments { + updates: self + .environments + .iter() + .map(|(id, environment)| { + (id.clone(), EnvironmentUpdate::Current(environment.clone())) + }) + .collect(), + legacy_single: is_legacy_single(&self.environments), + current_date: self.current_date.clone(), + timezone: self.timezone.clone(), + network: self.network.clone(), + filesystem: self.filesystem.clone(), + subagents: self.subagents.clone(), + } + } +} + +impl WorldStateSection for EnvironmentsState { + fn render_diff(&self, previous: Option<&Self>) -> Option> { + let empty = Self::default(); + let previous = previous.unwrap_or(&empty); + let turn_context_values_changed = self.current_date != previous.current_date + || self.timezone != previous.timezone + || self.network != previous.network + || self.filesystem != previous.filesystem; + let mut updates = self + .environments + .iter() + .filter(|(id, environment)| { + previous + .environments + .get(*id) + .is_none_or(|previous| !environment.has_same_diff_value(previous)) + }) + .map(|(id, environment)| (id.clone(), EnvironmentUpdate::Current(environment.clone()))) + .collect::>(); + updates.extend( + previous + .environments + .keys() + .filter(|id| !self.environments.contains_key(*id)) + .map(|id| (id.clone(), EnvironmentUpdate::Unavailable)), + ); + let legacy_single = is_legacy_single(&self.environments) + && updates + .values() + .all(|update| matches!(update, EnvironmentUpdate::Current(_))); + (!updates.is_empty() || turn_context_values_changed).then(|| { + Box::new(RenderedEnvironments { + updates, + legacy_single, + current_date: self.current_date.clone(), + timezone: self.timezone.clone(), + network: self.network.clone(), + filesystem: self.filesystem.clone(), + subagents: self.subagents.clone(), + }) as Box + }) + } +} + +impl ContextualUserFragment for EnvironmentsState { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + environment_context_markers() + } + + fn body(&self) -> String { + self.rendered_full().body() + } +} + +struct RenderedEnvironments { + updates: BTreeMap, + legacy_single: bool, + current_date: Option, + timezone: Option, + network: Option, + filesystem: Option, + subagents: Option, +} + +enum EnvironmentUpdate { + Current(EnvironmentState), + Unavailable, +} + +impl ContextualUserFragment for RenderedEnvironments { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + environment_context_markers() + } + + fn body(&self) -> String { + let mut rendered = "\n".to_string(); + if self.legacy_single { + if let Some(EnvironmentUpdate::Current(environment)) = self.updates.values().next() { + push_environment_values(&mut rendered, environment, " "); + } + } else if !self.updates.is_empty() { + rendered.push_str(" \n"); + for (id, update) in &self.updates { + match update { + EnvironmentUpdate::Current(environment) => { + rendered.push_str(" \n"); + push_environment_values(&mut rendered, environment, " "); + rendered.push_str(" \n"); + } + EnvironmentUpdate::Unavailable => { + rendered.push_str(" \n"); + } + } + } + rendered.push_str(" \n"); + } + push_optional_element(&mut rendered, "current_date", self.current_date.as_deref()); + push_optional_element(&mut rendered, "timezone", self.timezone.as_deref()); + if let Some(network) = &self.network { + rendered.push_str(" "); + rendered.push_str(&network.render()); + rendered.push('\n'); + } + if let Some(filesystem) = &self.filesystem { + rendered.push_str(" "); + rendered.push_str(&filesystem.render()); + rendered.push('\n'); + } + if let Some(subagents) = &self.subagents { + rendered.push_str(" \n"); + for line in subagents.lines() { + rendered.push_str(" "); + rendered.push_str(line); + rendered.push('\n'); + } + rendered.push_str(" \n"); + } + rendered + } +} + +fn push_environment_values(rendered: &mut String, environment: &EnvironmentState, indent: &str) { + rendered.push_str(indent); + rendered.push_str(""); + push_xml_escaped_text(rendered, &environment.cwd.inferred_native_path_string()); + rendered.push_str("\n"); + if environment.status == EnvironmentStatus::Starting { + rendered.push_str(indent); + rendered.push_str("starting\n"); + } + if let Some(shell) = &environment.shell { + rendered.push_str(indent); + rendered.push_str(""); + push_xml_escaped_text(rendered, shell); + rendered.push_str("\n"); + } +} + +fn push_optional_element(rendered: &mut String, name: &str, value: Option<&str>) { + let Some(value) = value else { + return; + }; + rendered.push_str(" <"); + rendered.push_str(name); + rendered.push('>'); + push_xml_escaped_text(rendered, value); + rendered.push_str("\n"); +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct EnvironmentState { + cwd: PathUri, + status: EnvironmentStatus, + shell: Option, +} + +impl EnvironmentState { + fn has_same_diff_value(&self, other: &Self) -> bool { + self.cwd == other.cwd + && self.status == other.status + && self + .shell + .as_ref() + .zip(other.shell.as_ref()) + .is_none_or(|(current, previous)| current == previous) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EnvironmentStatus { + Starting, + Available, +} + +fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap { + let mut environments = snapshot + .turn_environments + .iter() + .map(|environment| { + ( + environment.environment_id.clone(), + EnvironmentState { + cwd: environment.cwd().clone(), + status: EnvironmentStatus::Available, + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()), + }, + ) + }) + .collect::>(); + for environment in &snapshot.starting { + environments + .entry(environment.selection.environment_id.clone()) + .or_insert_with(|| EnvironmentState { + cwd: environment.selection.cwd.clone(), + status: EnvironmentStatus::Starting, + shell: None, + }); + } + environments +} + +fn is_legacy_single(environments: &BTreeMap) -> bool { + environments.len() == 1 + && environments + .values() + .all(|environment| environment.status == EnvironmentStatus::Available) +} + +fn environment_context_markers() -> (&'static str, &'static str) { + ( + codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG, + codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG, + ) +} + +fn network_from_turn_context(turn_context: &TurnContext) -> Option { + let network = turn_context + .config + .config_layer_stack + .requirements() + .network + .as_ref()?; + + Some(NetworkContext::new( + network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), + )) +} + +fn network_from_turn_context_item(turn_context_item: &TurnContextItem) -> Option { + let TurnContextNetworkItem { + allowed_domains, + denied_domains, + } = turn_context_item.network.as_ref()?; + Some(NetworkContext::new( + allowed_domains.clone(), + denied_domains.clone(), + )) +} + +fn workspace_roots_from_turn_context_item( + turn_context_item: &TurnContextItem, +) -> Vec { + if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() { + return workspace_roots.clone(); + } + + vec![turn_context_item.cwd.clone()] +} + +#[cfg(test)] +#[path = "environment_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "environment_render_tests.rs"] +mod render_tests; diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/world_state/environment_render_tests.rs similarity index 67% rename from codex-rs/core/src/context/environment_context_tests.rs rename to codex-rs/core/src/context/world_state/environment_render_tests.rs index d40ce4e671..be127de554 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_render_tests.rs @@ -30,15 +30,43 @@ fn test_abs_path(unix_path: &str) -> AbsolutePathBuf { test_path_buf(unix_path).abs() } +fn environment(id: &str, cwd: PathUri, shell: impl Into) -> (String, EnvironmentState) { + ( + id.to_string(), + EnvironmentState { + cwd, + status: EnvironmentStatus::Available, + shell: Some(shell.into()), + }, + ) +} + +fn environment_state( + environments: impl IntoIterator, + current_date: Option, + timezone: Option, + network: Option, + subagents: Option, +) -> EnvironmentsState { + EnvironmentsState { + environments: environments.into_iter().collect(), + current_date, + timezone, + network, + filesystem: None, + subagents, + } +} + #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&cwd.abs()), - shell: fake_shell_name(), - }], + let context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&cwd.abs()), + fake_shell_name(), + )], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), /*network*/ None, @@ -60,12 +88,12 @@ fn serialize_workspace_write_environment_context() { #[test] fn serialize_environment_context_with_foreign_windows_cwd() { - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "remote".to_string(), - cwd: PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), - shell: "powershell".to_string(), - }], + let context = environment_state( + [environment( + "remote", + PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), + "powershell", + )], /*current_date*/ None, /*timezone*/ None, /*network*/ None, @@ -87,12 +115,12 @@ fn serialize_environment_context_with_network() { vec!["api.example.com".to_string(), "*.openai.com".to_string()], vec!["blocked.example.com".to_string()], ); - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), - }], + let context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&test_abs_path("/repo")), + fake_shell_name(), + )], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), Some(network), @@ -149,12 +177,12 @@ fn serialize_environment_context_with_full_filesystem_profile() { AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), repo.as_path()); let other_repo_private_glob = AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), other_repo.as_path()); - let mut context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), - }], + let mut context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&test_abs_path("/repo")), + fake_shell_name(), + )], /*current_date*/ None, /*timezone*/ None, /*network*/ None, @@ -210,7 +238,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { summary: codex_protocol::config_types::ReasoningSummary::Auto, }; - let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render(); + let context = EnvironmentsState::from_turn_context_item(&item).render(); assert!( context.contains(&format!( @@ -237,7 +265,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { #[test] fn serialize_read_only_environment_context() { - let context = EnvironmentContext::new( + let context = environment_state( Vec::new(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -253,97 +281,14 @@ fn serialize_read_only_environment_context() { assert_eq!(context.render(), expected); } -#[test] -fn equals_except_shell_compares_cwd() { - let context1 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - let context2 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - assert!(context1.equals_except_shell(&context2)); -} - -#[test] -fn equals_except_shell_compares_cwd_differences() { - let context1 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo1")), - shell: fake_shell_name(), - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - let context2 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo2")), - shell: fake_shell_name(), - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - - assert!(!context1.equals_except_shell(&context2)); -} - -#[test] -fn equals_except_shell_ignores_shell() { - let context1 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: "bash".to_string(), - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - let context2 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "other".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: "zsh".to_string(), - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - - assert!(context1.equals_except_shell(&context2)); -} - #[test] fn serialize_environment_context_with_subagents() { - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), - }], + let context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&test_abs_path("/repo")), + fake_shell_name(), + )], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), /*network*/ None, @@ -371,18 +316,10 @@ fn serialize_environment_context_with_subagents() { fn serialize_environment_context_with_multiple_selected_environments() { let local_cwd = test_path_buf("/repo/local"); let remote_cwd = test_path_buf("/repo/remote"); - let context = EnvironmentContext::new( - vec![ - EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&local_cwd.abs()), - shell: "bash".to_string(), - }, - EnvironmentContextEnvironment { - id: "remote".to_string(), - cwd: PathUri::from_abs_path(&remote_cwd.abs()), - shell: "bash".to_string(), - }, + let context = environment_state( + [ + environment("local", PathUri::from_abs_path(&local_cwd.abs()), "bash"), + environment("remote", PathUri::from_abs_path(&remote_cwd.abs()), "bash"), ], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -416,18 +353,14 @@ fn serialize_environment_context_with_multiple_selected_environments() { fn serialize_environment_context_prefers_environment_shell_when_present() { let local_cwd = test_path_buf("/repo/local"); let remote_cwd = test_path_buf("/repo/remote"); - let context = EnvironmentContext::new( - vec![ - EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&local_cwd.abs()), - shell: "powershell".to_string(), - }, - EnvironmentContextEnvironment { - id: "remote".to_string(), - cwd: PathUri::from_abs_path(&remote_cwd.abs()), - shell: "cmd".to_string(), - }, + let context = environment_state( + [ + environment( + "local", + PathUri::from_abs_path(&local_cwd.abs()), + "powershell", + ), + environment("remote", PathUri::from_abs_path(&remote_cwd.abs()), "cmd"), ], /*current_date*/ None, /*timezone*/ None, diff --git a/codex-rs/core/src/context/world_state/environment_tests.rs b/codex-rs/core/src/context/world_state/environment_tests.rs new file mode 100644 index 0000000000..961ee5ed73 --- /dev/null +++ b/codex-rs/core/src/context/world_state/environment_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use crate::context::ContextualUserFragment; +use crate::context::world_state::WorldState; +use anyhow::Result; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_protocol::models::ContentItem; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::NetworkSandboxPolicy; +use pretty_assertions::assert_eq; + +#[test] +fn renders_full_environment_state() -> Result<()> { + let context = EnvironmentsState { + environments: [ + ("laptop".to_string(), available("file:///repo", "zsh")?), + ( + "devbox".to_string(), + available("file:///workspace", "bash")?, + ), + ] + .into_iter() + .collect(), + ..Default::default() + }; + + let mut world_state = WorldState::default(); + world_state.add_section(context); + + assert_eq!( + vec![user_message( + r#" + + + /workspace + bash + + + /repo + zsh + + +"#, + )], + render_fragments(world_state.render_full()), + ); + Ok(()) +} + +#[test] +fn renders_only_changed_environments() -> Result<()> { + let mut previous = WorldState::default(); + previous.add_section(EnvironmentsState { + environments: [ + ("laptop".to_string(), available("file:///repo", "bash")?), + ("devbox".to_string(), starting("file:///workspace")?), + ("old".to_string(), available("file:///old", "sh")?), + ] + .into_iter() + .collect(), + ..Default::default() + }); + let mut current = WorldState::default(); + current.add_section(EnvironmentsState { + environments: [ + ("laptop".to_string(), available("file:///repo", "zsh")?), + ( + "devbox".to_string(), + available("file:///workspace", "powershell")?, + ), + ("remote".to_string(), starting("file:///remote")?), + ] + .into_iter() + .collect(), + ..Default::default() + }); + + assert_eq!( + vec![user_message( + r#" + + + /workspace + powershell + + + /repo + zsh + + + + /remote + starting + + +"#, + )], + render_fragments(current.render_diff(&previous)), + ); + Ok(()) +} + +#[test] +fn persisted_turn_context_values_render_a_diff() -> Result<()> { + let environments = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + available("file:///repo", "zsh")?, + )] + .into_iter() + .collect(), + ..Default::default() + }; + let mut previous = WorldState::default(); + previous.add_section(EnvironmentsState { + current_date: Some("2026-06-19".to_string()), + timezone: Some("UTC".to_string()), + network: Some(NetworkContext::new( + vec!["old.example.com".to_string()], + vec![], + )), + filesystem: Some(FileSystemContext::from_permission_profile( + &PermissionProfile::Disabled, + &[], + )), + ..environments.clone() + }); + let mut current = WorldState::default(); + current.add_section(EnvironmentsState { + current_date: Some("2026-06-20".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + network: Some(NetworkContext::new( + vec!["new.example.com".to_string()], + vec!["blocked.example.com".to_string()], + )), + filesystem: Some(FileSystemContext::from_permission_profile( + &PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + &[], + )), + ..environments + }); + + assert_eq!( + vec![user_message( + r#" + 2026-06-20 + America/Los_Angeles + new.example.comblocked.example.com + +"#, + )], + render_fragments(current.render_diff(&previous)), + ); + Ok(()) +} + +#[test] +fn single_environment_diff_ignores_unknown_shell() -> Result<()> { + let previous = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + EnvironmentState { + cwd: PathUri::parse("file:///repo")?, + status: EnvironmentStatus::Available, + shell: None, + }, + )] + .into_iter() + .collect(), + ..Default::default() + }; + let current = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + available("file:///repo", "zsh")?, + )] + .into_iter() + .collect(), + ..Default::default() + }; + + assert_eq!( + None, + render_fragment(WorldStateSection::render_diff(¤t, Some(&previous))) + ); + Ok(()) +} + +#[test] +fn removed_legacy_environment_renders_unavailable() -> Result<()> { + let previous = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + available("file:///repo", "bash")?, + )] + .into_iter() + .collect(), + ..Default::default() + }; + + assert_eq!( + Some(user_message( + r#" + + + +"#, + )), + render_fragment(WorldStateSection::render_diff( + &EnvironmentsState::default(), + Some(&previous), + )), + ); + Ok(()) +} + +fn available(cwd: &str, shell: &str) -> Result { + Ok(EnvironmentState { + cwd: PathUri::parse(cwd)?, + status: EnvironmentStatus::Available, + shell: Some(shell.to_string()), + }) +} + +fn starting(cwd: &str) -> Result { + Ok(EnvironmentState { + cwd: PathUri::parse(cwd)?, + status: EnvironmentStatus::Starting, + shell: None, + }) +} + +fn render_fragments(fragments: Vec>) -> Vec { + fragments + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect() +} + +fn render_fragment(fragment: Option>) -> Option { + fragment.map(ContextualUserFragment::into_boxed_response_item) +} + +fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + } +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs new file mode 100644 index 0000000000..06f750ef86 --- /dev/null +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -0,0 +1,80 @@ +mod environment; + +use crate::context::ContextualUserFragment; +use indexmap::IndexMap; +use std::any::Any; +use std::any::TypeId; +use std::fmt; + +pub(crate) use environment::EnvironmentsState; + +trait ErasedWorldStateSection: Send + Sync { + fn as_any(&self) -> &dyn Any; + + fn render_diff(&self, previous: Option<&dyn Any>) -> Option>; +} + +impl ErasedWorldStateSection for S { + fn as_any(&self) -> &dyn Any { + self + } + + fn render_diff(&self, previous: Option<&dyn Any>) -> Option> { + let previous = match previous { + Some(previous) => { + let Some(previous) = previous.downcast_ref::() else { + unreachable!("world-state section type must match its type ID"); + }; + Some(previous) + } + None => None, + }; + WorldStateSection::render_diff(self, previous) + } +} + +/// A typed portion of the state visible to the model. +/// +/// Implementations own how their current state is rendered relative to an +/// earlier value of the same section type. A missing previous value requests +/// the section's complete current representation. +pub(crate) trait WorldStateSection: Any + Send + Sync { + fn render_diff(&self, previous: Option<&Self>) -> Option>; +} + +/// A snapshot of the model-visible world with one section per concrete type. +#[derive(Default)] +pub(crate) struct WorldState { + sections: IndexMap>, +} + +impl fmt::Debug for WorldState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WorldState") + .field("section_count", &self.sections.len()) + .finish() + } +} + +impl WorldState { + pub(crate) fn add_section(&mut self, section: S) { + self.sections.insert(TypeId::of::(), Box::new(section)); + } + + pub(crate) fn render_full(&self) -> Vec> { + self.render_diff(&Self::default()) + } + + pub(crate) fn render_diff(&self, previous: &Self) -> Vec> { + self.sections + .iter() + .filter_map(|(type_id, section)| { + let previous = previous + .sections + .get(type_id) + .map(|section| section.as_any()); + section.render_diff(previous) + }) + .collect() + } +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6ce1c6148f..b474ed1a9a 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,3 +1,5 @@ +use crate::context::ContextualUserFragment; +use crate::context::world_state::WorldState; use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; @@ -27,6 +29,7 @@ use codex_utils_output_truncation::truncate_function_output_items_with_policy; use codex_utils_output_truncation::truncate_text; use std::num::NonZeroUsize; use std::ops::Deref; +use std::sync::Arc; use std::sync::LazyLock; /// Transcript of thread history @@ -48,6 +51,8 @@ pub(crate) struct ContextManager { /// also clear this when it trims a mixed initial-context developer bundle /// whose non-diff fragments no longer exist in the surviving history. reference_context_item: Option, + /// World state most recently appended to model-visible history. + world_state_baseline: Option>, } impl ContextManager { @@ -59,6 +64,7 @@ impl ContextManager { &None, &None, /*model_context_window*/ None, ), reference_context_item: None, + world_state_baseline: None, } } @@ -78,6 +84,22 @@ impl ContextManager { self.reference_context_item.clone() } + pub(crate) fn update_world_state( + &mut self, + world_state: WorldState, + ) -> Vec> { + let fragments = self.world_state_baseline.as_deref().map_or_else( + || world_state.render_full(), + |previous| world_state.render_diff(previous), + ); + self.world_state_baseline = Some(Arc::new(world_state)); + fragments + } + + pub(crate) fn set_world_state_baseline(&mut self, world_state: WorldState) { + self.world_state_baseline = Some(Arc::new(world_state)); + } + pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { match &mut self.token_info { Some(info) => info.fill_to_context_window(context_window), @@ -163,12 +185,14 @@ impl ContextManager { // its corresponding counterpart to keep the invariants intact without // running a full normalization pass. normalize::remove_corresponding_for(&mut self.items, &removed); + self.world_state_baseline = None; } } pub(crate) fn replace(&mut self, items: Vec) { self.items = items; self.history_version = self.history_version.saturating_add(1); + self.world_state_baseline = None; } /// Replace image content in the last turn if it originated from a tool output. @@ -342,27 +366,28 @@ impl ContextManager { id, call_id, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::CustomToolCallOutput { id, call_id, name, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::CustomToolCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -452,7 +477,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -698,7 +724,8 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::AgentMessage { .. } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 35a9656fe1..da2f14fd4f 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,4 +1,6 @@ use super::*; +use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::WorldState; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; @@ -9,12 +11,12 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::LocalShellAction; use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; @@ -42,7 +44,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -61,7 +63,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { text: serde_json::to_string(&communication).unwrap(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -73,6 +75,25 @@ fn create_history_with_items(items: Vec) -> ContextManager { h } +#[test] +fn world_state_baseline_deduplicates_until_history_is_replaced() { + let world_state = || { + let mut state = WorldState::default(); + state.add_section(EnvironmentsState::from_turn_context_item( + &reference_context_item(), + )); + state + }; + let mut history = ContextManager::new(); + + assert_eq!(1, history.update_world_state(world_state()).len()); + assert!(history.update_world_state(world_state()).is_empty()); + + history.replace(Vec::new()); + + assert_eq!(1, history.update_world_state(world_state()).len()); +} + fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -81,7 +102,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -93,7 +114,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -105,7 +126,7 @@ fn developer_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -120,7 +141,7 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { }) .collect(), phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -159,7 +180,7 @@ fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { call_id: call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_text(output.to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -173,7 +194,7 @@ fn reasoning_msg(text: &str) -> ResponseItem { text: text.to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -185,7 +206,7 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { }], content: None, encrypted_content: Some("a".repeat(len)), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -209,7 +230,7 @@ fn filters_non_api_messages() { text: "ignored".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let reasoning = reasoning_msg("thinking..."); h.record_items([&system, &reasoning, &ResponseItem::Other], policy); @@ -232,7 +253,7 @@ fn filters_non_api_messages() { text: "thinking...".to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -241,7 +262,7 @@ fn filters_non_api_messages() { text: "hi".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -250,7 +271,7 @@ fn filters_non_api_messages() { text: "hello".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } ] ); @@ -400,7 +421,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -408,7 +429,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -422,7 +443,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -430,7 +451,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -445,7 +466,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let history = create_history_with_items(items); @@ -469,7 +490,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -477,7 +498,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -491,7 +512,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { .to_string(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -499,7 +520,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -514,7 +535,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { .to_string(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(stripped, expected); @@ -534,7 +555,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]); let preserved = with_images.for_prompt(&modalities); assert_eq!(preserved.len(), 1); @@ -554,7 +575,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -563,7 +584,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { text: "hi".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]); @@ -575,7 +596,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -584,7 +605,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { text: "hi".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } ] ); @@ -600,14 +621,14 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { text: "generate a lobster".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]); @@ -621,14 +642,14 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { text: "generate a lobster".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: String::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -665,13 +686,13 @@ fn remove_first_item_removes_matching_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -686,7 +707,7 @@ fn remove_first_item_removes_matching_call_for_output() { id: None, call_id: "call-2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -694,7 +715,7 @@ fn remove_first_item_removes_matching_call_for_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-2".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -718,7 +739,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut history = create_history_with_items(items); @@ -740,7 +761,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -756,7 +777,7 @@ fn replace_last_turn_images_does_not_touch_user_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut history = create_history_with_items(items.clone()); @@ -778,13 +799,13 @@ fn remove_first_item_handles_local_shell_pair() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-3".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1007,14 +1028,14 @@ fn remove_first_item_handles_custom_tool_pair() { call_id: "tool-1".to_string(), name: "my_tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1036,13 +1057,13 @@ fn normalization_retains_local_shell_outputs() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("Total output lines: 1\n\nok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -1067,9 +1088,8 @@ fn record_items_truncates_function_call_output_content() { body: FunctionCallOutputBody::Text(long_output.clone()), success: Some(true), }, - metadata: Some(ResponseItemMetadata { + internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { turn_id: Some("turn-1".to_string()), - ..Default::default() }), }; @@ -1105,7 +1125,7 @@ fn record_items_truncates_custom_tool_call_output_content() { call_id: "tool-200".to_string(), name: None, output: FunctionCallOutputPayload::from_text(long_output.clone()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&item], policy); @@ -1140,7 +1160,7 @@ fn record_items_respects_custom_token_limit() { body: FunctionCallOutputBody::Text(long_output), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&item], policy); @@ -1260,7 +1280,7 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1275,13 +1295,13 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1296,7 +1316,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1311,14 +1331,14 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "tool-x".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1338,7 +1358,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1358,13 +1378,13 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1377,7 +1397,7 @@ fn normalize_removes_orphan_function_call_output() { id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1394,7 +1414,7 @@ fn normalize_removes_orphan_custom_tool_call_output() { call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1414,14 +1434,14 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Orphan output that should be removed ResponseItem::FunctionCallOutput { id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Will get an inserted custom tool output ResponseItem::CustomToolCall { @@ -1430,7 +1450,7 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Local shell call also gets an inserted function call output ResponseItem::LocalShellCall { @@ -1444,7 +1464,7 @@ fn normalize_mixed_inserts_and_removals() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1460,13 +1480,13 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "c1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -1474,14 +1494,14 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "t1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::LocalShellCall { id: None, @@ -1494,13 +1514,13 @@ fn normalize_mixed_inserts_and_removals() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "s1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1514,7 +1534,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1527,13 +1547,13 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1547,7 +1567,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: Some("completed".to_string()), execution: "client".to_string(), arguments: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1562,7 +1582,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: Some("completed".to_string()), execution: "client".to_string(), arguments: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchOutput { id: None, @@ -1570,7 +1590,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1586,7 +1606,7 @@ fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1607,7 +1627,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1621,7 +1641,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() { id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1636,7 +1656,7 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1651,7 +1671,7 @@ fn normalize_removes_orphan_client_tool_search_output() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1670,7 +1690,7 @@ fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1684,7 +1704,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { status: "completed".to_string(), execution: "server".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1698,7 +1718,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { status: "completed".to_string(), execution: "server".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -1714,13 +1734,13 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -1728,7 +1748,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::LocalShellCall { id: None, @@ -1741,7 +1761,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1765,7 +1785,7 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let text_only_item = ResponseItem::Message { id: None, @@ -1774,7 +1794,7 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { text: "Here is the screenshot".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&image_item).unwrap().len() as i64; @@ -1803,7 +1823,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1831,7 +1851,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1852,7 +1872,7 @@ fn non_base64_image_urls_are_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let function_output_item = ResponseItem::FunctionCallOutput { id: None, @@ -1863,7 +1883,7 @@ fn non_base64_image_urls_are_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!( @@ -1887,7 +1907,7 @@ fn encrypted_function_output_uses_plaintext_byte_estimate() { encrypted_content: encrypted_content.clone(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1908,7 +1928,7 @@ fn data_url_without_base64_marker_is_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!( @@ -1930,7 +1950,7 @@ fn non_image_base64_data_url_is_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1951,7 +1971,7 @@ fn mixed_case_data_url_markers_are_adjusted() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1984,7 +2004,7 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2019,7 +2039,7 @@ fn original_detail_images_scale_with_dimensions() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2051,7 +2071,7 @@ fn original_detail_images_are_capped_at_max_patch_count() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2086,7 +2106,7 @@ fn original_detail_webp_images_scale_with_dimensions() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2105,7 +2125,7 @@ fn text_only_items_unchanged() { text: "Hello world, this is a response.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let estimated = estimate_response_item_model_visible_bytes(&item); diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 13aacca1e6..d11cffe4e9 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -50,7 +50,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -67,7 +67,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -84,7 +84,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { call_id: call_id.clone(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -102,7 +102,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 7cf877a728..937c4bf059 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,6 +1,5 @@ use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; -use crate::context::EnvironmentContext; use crate::context::ModelSwitchInstructions; use crate::context::MultiAgentModeInstructions; use crate::context::PermissionsInstructions; @@ -10,7 +9,6 @@ use crate::context::RealtimeStartInstructions; use crate::context::RealtimeStartWithInstructions; use crate::session::PreviousTurnSettings; use crate::session::turn_context::TurnContext; -use crate::shell::Shell; use codex_execpolicy::Policy; use codex_features::Feature; use codex_protocol::config_types::MultiAgentMode; @@ -20,27 +18,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::TurnContextItem; -fn build_environment_update_item( - previous: Option<&TurnContextItem>, - next: &TurnContext, - shell: &Shell, -) -> Option { - if !next.config.include_environment_context { - return None; - } - - let prev = previous?; - let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string()); - let next_context = EnvironmentContext::from_turn_context(next, shell); - if prev_context.equals_except_shell(&next_context) { - return None; - } - - Some(ContextualUserFragment::into( - EnvironmentContext::diff_from_turn_context_item(prev, &next_context), - )) -} - fn build_permissions_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, @@ -103,10 +80,8 @@ fn build_multi_agent_mode_update_item( ) -> Option { let effective_multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( next.multi_agent_version, - &next.config.multi_agent_v2, &next.session_source, next.multi_agent_mode, - next.config.features.enabled(Feature::MultiAgentMode), ); let previous = previous?; if previous.multi_agent_mode == effective_multi_agent_mode { @@ -114,6 +89,9 @@ fn build_multi_agent_mode_update_item( } match effective_multi_agent_mode { + Some(MultiAgentMode::None) => { + Some(MultiAgentModeInstructions::new(MultiAgentMode::None).render()) + } Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()), None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => { Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render()) @@ -219,6 +197,26 @@ pub(crate) fn build_contextual_user_message(text_sections: Vec) -> Optio build_text_message("user", text_sections) } +pub(crate) fn merge_contextual_fragments( + fragments: Vec>, +) -> Vec { + let mut messages: Vec<(&str, Vec)> = Vec::with_capacity(fragments.len()); + for fragment in fragments { + let role = fragment.role(); + let text = fragment.render(); + match messages.last_mut() { + Some((previous_role, text_sections)) if *previous_role == role => { + text_sections.push(text); + } + _ => messages.push((role, vec![text])), + } + } + messages + .into_iter() + .filter_map(|(role, text_sections)| build_text_message(role, text_sections)) + .collect() +} + fn build_text_message(role: &str, text_sections: Vec) -> Option { if text_sections.is_empty() { return None; @@ -234,7 +232,7 @@ fn build_text_message(role: &str, text_sections: Vec) -> Option, previous_turn_settings: Option<&PreviousTurnSettings>, next: &TurnContext, - shell: &Shell, exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { @@ -250,7 +247,6 @@ pub(crate) fn build_settings_update_items( // model-visible item emitted by build_initial_context. Persist the remaining // inputs or add explicit replay events so fork/resume can diff everything // deterministically. - let contextual_user_message = build_environment_update_item(previous, next, shell); let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before // any other context diffs on this turn. @@ -265,12 +261,7 @@ pub(crate) fn build_settings_update_items( .flatten() .collect(); - let mut items = Vec::with_capacity(2); - if let Some(developer_message) = build_developer_update_item(developer_update_sections) { - items.push(developer_message); - } - if let Some(contextual_user_message) = contextual_user_message { - items.push(contextual_user_message); - } - items + build_developer_update_item(developer_update_sections) + .into_iter() + .collect() } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 1ca097e783..07f06c44b6 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -15,6 +15,7 @@ use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_close_tag_text; use codex_protocol::models::is_local_image_open_tag_text; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; @@ -34,7 +35,9 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", + // Keep recognizing token-budget wrappers persisted by older versions. "", + CONTEXT_WINDOW_OPEN_TAG, "", ]; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index b7453c902f..d7e999afb9 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -15,6 +15,8 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; @@ -29,7 +31,7 @@ fn recognizes_skills_instructions_as_contextual_developer_content() { } #[test] -fn recognizes_token_budget_as_contextual_developer_content() { +fn recognizes_legacy_token_budget_as_contextual_developer_content() { let content = vec![ContentItem::InputText { text: "\nYou have 710 tokens left in this context window.\n" .to_string(), @@ -39,6 +41,20 @@ fn recognizes_token_budget_as_contextual_developer_content() { assert!(!has_non_contextual_dev_message_content(&content)); } +#[test] +fn recognizes_context_window_as_contextual_developer_content() { + let content = vec![ContentItem::InputText { + text: format!( + r#"{CONTEXT_WINDOW_OPEN_TAG} +Thread id: 00000000-0000-0000-0000-000000000000 +{CONTEXT_WINDOW_CLOSE_TAG}"# + ), + }]; + + assert!(is_contextual_dev_message_content(&content)); + assert!(!has_non_contextual_dev_message_content(&content)); +} + #[test] fn parses_user_message_with_text_and_two_images() { let img1 = "https://example.com/one.png".to_string(); @@ -61,7 +77,7 @@ fn parses_user_message_with_text_and_two_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected user message turn item"); @@ -111,7 +127,7 @@ fn skips_local_image_label_text() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected user message turn item"); @@ -144,7 +160,7 @@ fn parses_assistant_message_input_text_for_backward_compatibility() { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected assistant message turn item"); @@ -194,7 +210,7 @@ fn skips_unnamed_image_label_text() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected user message turn item"); @@ -227,7 +243,7 @@ fn skips_user_instructions_and_env() { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -235,7 +251,7 @@ fn skips_user_instructions_and_env() { text: "test_text".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -243,7 +259,7 @@ fn skips_user_instructions_and_env() { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -252,7 +268,7 @@ fn skips_user_instructions_and_env() { .to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -260,7 +276,7 @@ fn skips_user_instructions_and_env() { text: "echo 42".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -275,7 +291,7 @@ fn skips_user_instructions_and_env() { }, ], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ]; for item in items { @@ -325,7 +341,7 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { }, ], phase: None, - metadata: None,}; + internal_chat_message_metadata_passthrough: None,}; let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item"); @@ -357,7 +373,7 @@ fn internal_model_context_does_not_parse_as_visible_turn_item() { .render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!(parse_turn_item(&item).is_none()); @@ -372,7 +388,7 @@ fn parses_agent_message() { text: "Hello from Codex".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected agent message turn item"); @@ -404,7 +420,7 @@ fn parses_reasoning_summary_and_raw_content() { text: "raw details".to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); @@ -437,7 +453,7 @@ fn parses_reasoning_including_raw_content() { }, ]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); @@ -463,7 +479,7 @@ fn parses_web_search_call() { query: Some("weather".to_string()), queries: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -492,7 +508,7 @@ fn parses_web_search_open_page_call() { action: Some(WebSearchAction::OpenPage { url: Some("https://example.com".to_string()), }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -521,7 +537,7 @@ fn parses_web_search_find_in_page_call() { url: Some("https://example.com".to_string()), pattern: Some("needle".to_string()), }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -548,7 +564,7 @@ fn parses_partial_web_search_call_without_action_as_other() { id: Some("ws_partial".to_string()), status: Some("in_progress".to_string()), action: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9d55ae8864..647203db2e 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -42,6 +42,7 @@ use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::WindowsSandboxFilesystemOverrides; +pub(crate) use codex_sandboxing::is_likely_sandbox_denied; #[cfg(test)] use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; @@ -456,6 +457,8 @@ pub(crate) async fn execute_exec_request( windows_sandbox_filesystem_overrides, network_environment_id, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = exec_request; // TODO(anp): Keep PathUri through the local process launch boundary. @@ -817,68 +820,6 @@ fn finalize_exec_result( } } -/// We don't have a fully deterministic way to tell if our command failed -/// because of the sandbox - a command in the user's zshrc file might hit an -/// error, but the command itself might fail or succeed for other reasons. -/// For now, we conservatively check for well known command failure exit codes and -/// also look for common sandbox denial keywords in the command output. -pub(crate) fn is_likely_sandbox_denied( - sandbox_type: SandboxType, - exec_output: &ExecToolCallOutput, -) -> bool { - if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { - return false; - } - - // Quick rejects: well-known non-sandbox shell exit codes - // 2: misuse of shell builtins - // 126: permission denied - // 127: command not found - const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ - "operation not permitted", - "permission denied", - "read-only file system", - "seccomp", - "sandbox", - "landlock", - "failed to write file", - ]; - - let has_sandbox_keyword = [ - &exec_output.stderr.text, - &exec_output.stdout.text, - &exec_output.aggregated_output.text, - ] - .into_iter() - .any(|section| { - let lower = section.to_lowercase(); - SANDBOX_DENIED_KEYWORDS - .iter() - .any(|needle| lower.contains(needle)) - }); - - if has_sandbox_keyword { - return true; - } - - const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; - if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { - return false; - } - - #[cfg(unix)] - { - const SIGSYS_CODE: i32 = libc::SIGSYS; - if sandbox_type == SandboxType::LinuxSeccomp - && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE - { - return true; - } - } - - false -} - #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 92223083d8..4756cc1fc4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -665,40 +665,23 @@ pub(crate) fn spawn_approval_request_review( rx } -/// Runs the guardian in a locked-down reusable review session. -/// -/// The guardian itself should not mutate state or trigger further approvals, so -/// it is pinned to a read-only sandbox with `approval_policy = never` and -/// nonessential agent features disabled. When the cached trunk session is idle, -/// later approvals append onto that same guardian conversation to preserve a -/// stable prompt-cache key. If the trunk is already busy, the review runs in an -/// ephemeral fork from the last committed trunk rollout so parallel approvals -/// do not block each other or mutate the cached thread. The trunk is recreated -/// when the effective review-session config changes, and any future compaction -/// must continue to preserve the guardian policy as exact top-level developer -/// context. It may still reuse the parent's managed-network allowlist for -/// read-only checks, but it intentionally runs without inherited exec-policy -/// rules. -async fn run_guardian_review_session_before_deadline( - session: Arc, - turn: Arc, - request: GuardianApprovalRequest, - retry_reason: Option, - schema: serde_json::Value, - external_cancel: Option, - deadline: Instant, -) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { +pub(super) struct GuardianReviewSessionConfig { + pub(super) spawn_config: crate::config::Config, + model: String, + reasoning_effort: Option, + default_review_model_id: String, + catalog_contains_auto_review: bool, + model_overridden: bool, + model_override: Option, +} + +pub(super) async fn guardian_review_session_config( + session: &Session, + turn: &TurnContext, +) -> anyhow::Result { let network_proxy = session.services.network_proxy.load_full(); let live_network_config = match network_proxy.as_ref() { - Some(network_proxy) => match network_proxy.proxy().current_cfg().await { - Ok(config) => Some(config), - Err(err) => { - return ( - GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), - GuardianReviewAnalyticsResult::without_session(), - ); - } - }, + Some(network_proxy) => Some(network_proxy.proxy().current_cfg().await?), None => None, }; let available_models = session @@ -750,14 +733,50 @@ async fn run_guardian_review_session_before_deadline( reasoning_effort, ) }; - let guardian_config = build_guardian_review_session_config( + + let spawn_config = build_guardian_review_session_config( turn.config.as_ref(), - live_network_config.clone(), + live_network_config, guardian_model.as_str(), guardian_reasoning_effort.clone(), - ); - let guardian_config = match guardian_config { - Ok(config) => config, + )?; + Ok(GuardianReviewSessionConfig { + spawn_config, + model: guardian_model, + reasoning_effort: guardian_reasoning_effort, + default_review_model_id: default_review_model_id.to_string(), + catalog_contains_auto_review: guardian_catalog_contains_auto_review, + model_overridden: guardian_review_model_overridden, + model_override: guardian_review_model_override, + }) +} + +/// Runs the guardian in a locked-down reusable review session. +/// +/// The guardian itself should not mutate state or trigger further approvals, so +/// it is pinned to a read-only sandbox with `approval_policy = never` and +/// nonessential agent features disabled. When the cached trunk session is idle, +/// later approvals append onto that same guardian conversation to preserve a +/// stable prompt-cache key. If the trunk is already busy, the review runs in an +/// ephemeral fork from the last committed trunk rollout so parallel approvals +/// do not block each other or mutate the cached thread. The trunk is recreated +/// when the effective review-session config changes, and any future compaction +/// must continue to preserve the guardian policy as exact top-level developer +/// context. It may still reuse the parent's managed-network allowlist for +/// read-only checks, but it intentionally runs without inherited exec-policy +/// rules. +async fn run_guardian_review_session_before_deadline( + session: Arc, + turn: Arc, + request: GuardianApprovalRequest, + retry_reason: Option, + schema: serde_json::Value, + external_cancel: Option, + deadline: Instant, +) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { + let session_config = match guardian_review_session_config(session.as_ref(), turn.as_ref()).await + { + Ok(session_config) => session_config, Err(err) => { return ( GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), @@ -765,23 +784,22 @@ async fn run_guardian_review_session_before_deadline( ); } }; - let (session_outcome, session_analytics_result) = Box::pin( session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), parent_turn: turn.clone(), - spawn_config: guardian_config, + spawn_config: session_config.spawn_config, request, retry_reason, schema, - model: guardian_model, - reasoning_effort: guardian_reasoning_effort, - guardian_default_review_model_id: default_review_model_id.to_string(), - guardian_catalog_contains_auto_review, - guardian_review_model_overridden, - guardian_review_model_override, + model: session_config.model, + reasoning_effort: session_config.reasoning_effort, + guardian_default_review_model_id: session_config.default_review_model_id, + guardian_catalog_contains_auto_review: session_config.catalog_contains_auto_review, + guardian_review_model_overridden: session_config.model_overridden, + guardian_review_model_override: session_config.model_override, reasoning_summary: turn.reasoning_summary, personality: turn.personality, external_cancel, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7aa007891f..de56de41a6 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -27,6 +27,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; +use futures::future::BoxFuture; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::Semaphore; @@ -56,6 +57,7 @@ use super::prompt::GuardianTranscriptCursor; use super::prompt::build_guardian_prompt_items_with_parent_turn; use super::prompt::guardian_policy_prompt; use super::prompt::guardian_policy_prompt_with_config; +use super::review::guardian_review_session_config; const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug)] @@ -92,6 +94,7 @@ pub(crate) struct GuardianReviewSessionParams { #[derive(Default)] pub(crate) struct GuardianReviewSessionManager { state: Arc>, + cancellation_token: CancellationToken, } #[derive(Default)] @@ -289,6 +292,42 @@ impl Drop for EphemeralReviewCleanup { } impl GuardianReviewSessionManager { + pub(crate) fn initialize( + &self, + parent_session: Arc, + parent_turn: Arc, + ) -> BoxFuture<'_, anyhow::Result<()>> { + // Boxing breaks the Session::new -> Guardian -> Session::new future recursion. + Box::pin(async move { + let spawn_config = guardian_review_session_config(&parent_session, &parent_turn) + .await? + .spawn_config; + let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + &spawn_config, + parent_session.user_instructions().await, + ); + let spawn_cancel_token = self.cancellation_token.child_token(); + let spawn_cancel_guard = spawn_cancel_token.clone().drop_guard(); + let review_session = spawn_guardian_review_session( + &parent_session, + &parent_turn, + spawn_config, + reuse_key, + spawn_cancel_token.clone(), + /*fork_snapshot*/ None, + ) + .await?; + // A first review or shutdown may win while eager initialization is in flight; + // install only if neither has happened. + let mut state = self.state.lock().await; + if !spawn_cancel_token.is_cancelled() && state.trunk.is_none() { + state.trunk = Some(Arc::new(review_session)); + drop(spawn_cancel_guard.disarm()); + } + Ok(()) + }) + } + pub(crate) async fn trunk_rollout_path(&self) -> Option { let trunk = self.state.lock().await.trunk.clone()?; trunk.codex.session.ensure_rollout_materialized().await; @@ -302,6 +341,7 @@ impl GuardianReviewSessionManager { } pub(crate) async fn shutdown(&self) { + self.cancellation_token.cancel(); let (review_session, ephemeral_reviews) = { let mut state = self.state.lock().await; ( @@ -348,13 +388,14 @@ impl GuardianReviewSessionManager { } if state.trunk.is_none() { - let spawn_cancel_token = CancellationToken::new(); + let spawn_cancel_token = self.cancellation_token.child_token(); let review_session = match run_before_review_deadline_with_cancel( deadline, params.external_cancel.as_ref(), &spawn_cancel_token, Box::pin(spawn_guardian_review_session( - ¶ms, + ¶ms.parent_session, + ¶ms.parent_turn, params.spawn_config.clone(), next_reuse_key.clone(), spawn_cancel_token.clone(), @@ -556,7 +597,7 @@ impl GuardianReviewSessionManager { deadline: tokio::time::Instant, fork_snapshot: Option, ) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) { - let spawn_cancel_token = CancellationToken::new(); + let spawn_cancel_token = self.cancellation_token.child_token(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; let review_session = match run_before_review_deadline_with_cancel( @@ -564,7 +605,8 @@ impl GuardianReviewSessionManager { params.external_cancel.as_ref(), &spawn_cancel_token, Box::pin(spawn_guardian_review_session( - ¶ms, + ¶ms.parent_session, + ¶ms.parent_turn, fork_config, reuse_key, spawn_cancel_token.clone(), @@ -605,7 +647,8 @@ impl GuardianReviewSessionManager { } async fn spawn_guardian_review_session( - params: &GuardianReviewSessionParams, + parent_session: &Arc, + parent_turn: &Arc, spawn_config: Config, reuse_key: GuardianReviewSessionReuseKey, cancel_token: CancellationToken, @@ -621,10 +664,10 @@ async fn spawn_guardian_review_session( }; let codex = Box::pin(run_codex_thread_interactive( spawn_config, - params.parent_session.services.auth_manager.clone(), - params.parent_session.services.models_manager.clone(), - Arc::clone(¶ms.parent_session), - Arc::clone(¶ms.parent_turn), + parent_session.services.auth_manager.clone(), + parent_session.services.models_manager.clone(), + Arc::clone(parent_session), + Arc::clone(parent_turn), cancel_token.clone(), SubAgentSource::Other(GUARDIAN_REVIEWER_NAME.to_string()), initial_history, @@ -1546,6 +1589,7 @@ mod tests { trunk: Some(Arc::new(review_session)), ephemeral_reviews: Vec::new(), })), + ..Default::default() }; drop(tx_event); diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 15024990a2..db8c0647f3 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -298,7 +298,7 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc, turn: &Arc, turn: &Arc, turn: &Arc anyh text: "Please also push the second docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -540,7 +540,7 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh text: "I need approval for the second push.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -663,7 +663,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Compacted retained user request.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -672,7 +672,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Compacted summary of earlier guardian context.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], /*reference_context_item*/ None, @@ -689,7 +689,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Please push after the compaction.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -698,7 +698,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "I need approval for the post-compaction push.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -746,7 +746,7 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() { text: "\n/tmp\n".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -755,7 +755,7 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -783,7 +783,7 @@ fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() text: "ordinary developer context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -792,7 +792,7 @@ fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() text: approval_text.clone(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -817,7 +817,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { text: "check the repo".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -825,7 +825,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { namespace: None, arguments: "{\"path\":\"README.md\"}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -833,7 +833,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { output: codex_protocol::models::FunctionCallOutputPayload::from_text( "repo is public".to_string(), ), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -842,7 +842,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { text: "I need to push a fix".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -1698,7 +1698,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() ), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -1930,7 +1930,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "Please push the second docs fix too.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1939,7 +1939,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "I need approval for the second docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -1977,7 +1977,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "Please push the third docs fix too.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1986,7 +1986,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "I need approval for the third docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -2726,7 +2726,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "Please inspect pending changes before pushing.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "assistant".to_string(), @@ -2734,7 +2734,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "I need approval to run git diff.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ], ) .await; @@ -2793,7 +2793,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "Now inspect whether pushing is safe.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "assistant".to_string(), @@ -2801,7 +2801,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "I need approval to push after the diff check.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ], ) .await; diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index d30dfbb5ca..111ce9b955 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -13,6 +13,8 @@ pub(crate) const IMAGE_PROCESSING_ERROR_PLACEHOLDER: &str = const IMAGE_TOO_LARGE_PLACEHOLDER: &str = "image content omitted because it exceeded the supported size limit; use a smaller image"; const UNSUPPORTED_LOW_DETAIL_PLACEHOLDER: &str = "image content omitted because detail 'low' is not supported; use 'high', 'original', or 'auto'"; +const REMOTE_IMAGE_URL_PLACEHOLDER: &str = + "image content omitted because remote image URLs are not supported"; const HIGH_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits { max_dimension: 2048, @@ -24,6 +26,8 @@ const ORIGINAL_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits }; #[derive(Debug, thiserror::Error)] enum ImagePreparationError { + #[error("remote image URLs are not supported")] + RemoteUrlUnsupported, #[error("image detail `low` is not supported")] UnsupportedLowDetail, #[error(transparent)] @@ -33,6 +37,7 @@ enum ImagePreparationError { impl ImagePreparationError { fn placeholder(&self) -> &'static str { match self { + ImagePreparationError::RemoteUrlUnsupported => REMOTE_IMAGE_URL_PLACEHOLDER, ImagePreparationError::UnsupportedLowDetail => UNSUPPORTED_LOW_DETAIL_PLACEHOLDER, ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge { .. }) => { IMAGE_TOO_LARGE_PLACEHOLDER @@ -52,7 +57,8 @@ pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) { prepare_tool_output_content(content); } } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -72,7 +78,6 @@ pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) { fn prepare_message_content(items: &mut [ContentItem]) { for item in items { if let ContentItem::InputImage { image_url, detail } = item - && is_data_url(image_url) && let Err(error) = prepare_image(image_url, *detail) { warn!(%error, "failed to prepare message image"); @@ -86,7 +91,6 @@ fn prepare_message_content(items: &mut [ContentItem]) { fn prepare_tool_output_content(items: &mut [FunctionCallOutputContentItem]) { for item in items { if let FunctionCallOutputContentItem::InputImage { image_url, detail } = item - && is_data_url(image_url) && let Err(error) = prepare_image(image_url, *detail) { warn!(%error, "failed to prepare tool output image"); @@ -97,6 +101,12 @@ fn prepare_tool_output_content(items: &mut [FunctionCallOutputContentItem]) { } } +fn is_remote_image_url(image_url: &str) -> bool { + image_url.split_once(':').is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) +} + fn is_data_url(image_url: &str) -> bool { image_url .get(.."data:".len()) @@ -107,6 +117,13 @@ fn prepare_image( image_url: &mut String, detail: Option, ) -> Result<(), ImagePreparationError> { + if is_remote_image_url(image_url) { + return Err(ImagePreparationError::RemoteUrlUnsupported); + } + if !is_data_url(image_url) { + return Ok(()); + } + let limits = match detail { None | Some(ImageDetail::Auto | ImageDetail::High) => HIGH_DETAIL_LIMITS, Some(ImageDetail::Original) => ORIGINAL_DETAIL_LIMITS, diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 8ddbd76965..659eb2b334 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -32,9 +32,8 @@ fn decoded_image(image_url: &str) -> (Vec, DynamicImage) { } #[test] -fn preparation_preserves_small_image_bytes_and_non_data_urls() { +fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { let (data_url, original_bytes) = png_data_url(/*width*/ 64, /*height*/ 32); - let http_url = "https://example.com/image.png".to_string(); let mut items = vec![ResponseItem::Message { id: None, role: "user".to_string(), @@ -44,12 +43,12 @@ fn preparation_preserves_small_image_bytes_and_non_data_urls() { detail: Some(ImageDetail::High), }, ContentItem::InputImage { - image_url: http_url.clone(), + image_url: "https://example.com/image.png".to_string(), detail: Some(ImageDetail::Low), }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -59,16 +58,13 @@ fn preparation_preserves_small_image_bytes_and_non_data_urls() { }; let [ ContentItem::InputImage { image_url, .. }, - ContentItem::InputImage { - image_url: preserved_http_url, - detail: Some(ImageDetail::Low), - }, + ContentItem::InputText { text }, ] = content.as_slice() else { panic!("expected two images"); }; assert_eq!(decoded_image(image_url).0, original_bytes); - assert_eq!(preserved_http_url, &http_url); + assert_eq!(text, REMOTE_IMAGE_URL_PLACEHOLDER); } #[test] @@ -86,7 +82,7 @@ fn detail_policies_apply_the_expected_budgets() { role: "user".to_string(), content: vec![ContentItem::InputImage { image_url, detail }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -133,7 +129,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -165,7 +161,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -173,6 +169,10 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { #[test] fn preparation_errors_use_bounded_actionable_placeholders() { let cases = [ + ( + ImagePreparationError::RemoteUrlUnsupported, + REMOTE_IMAGE_URL_PLACEHOLDER, + ), ( ImagePreparationError::UnsupportedLowDetail, UNSUPPORTED_LOW_DETAIL_PLACEHOLDER, diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 4dee0da717..5044568cd6 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -8,8 +8,8 @@ //! and rewrite only the declared arguments into the provided-file payload //! shape expected by the downstream Apps tool. //! -//! Model-visible schema masking is owned by `codex-mcp` alongside MCP tool -//! inventory, so this module only handles the execution-time argument rewrite. +//! The model-facing local-path schema is owned by `codex-mcp` alongside MCP tool inventory, so this +//! module only handles uploading the files and rewriting the execution-time arguments. use crate::session::session::Session; use crate::session::turn_context::TurnContext; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 56d6e678a0..0cb0c11a2a 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -744,7 +744,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( }; let permission_profile = turn_context.permission_profile(); let sandbox_state = serde_json::to_value(SandboxState { - permission_profile: Some(permission_profile), + permission_profile, codex_linux_sandbox_exe: turn_context.config.codex_linux_sandbox_exe.clone(), sandbox_cwd, use_legacy_landlock: turn_context.config.features.use_legacy_landlock(), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index eb500b0f6c..f8b94de5aa 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1335,15 +1335,25 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata { async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) { let auth = session.services.auth_manager.auth().await; + let startup_cancellation_token = CancellationToken::new(); + startup_cancellation_token.cancel(); + let (tx_event, _rx_event) = async_channel::unbounded(); + let mcp_servers = HashMap::from([( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + codex_mcp::EffectiveMcpServer::configured(codex_mcp::codex_apps_mcp_server_config( + "https://chatgpt.com", + /*apps_mcp_product_sku*/ None, + )), + )]); let manager = codex_mcp::McpConnectionManager::new( - &HashMap::new(), + &mcp_servers, turn_context.config.mcp_oauth_credentials_store_mode, turn_context.config.auth_keyring_backend_kind(), HashMap::new(), &turn_context.approval_policy, turn_context.sub_id.clone(), - session.get_tx_event(), - CancellationToken::new(), + tx_event, + startup_cancellation_token, turn_context.permission_profile(), codex_mcp::McpRuntimeContext::new( session.services.turn_environments.environment_manager(), @@ -1354,7 +1364,6 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: ), turn_context.config.codex_home.to_path_buf(), codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), - /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index d62880615f..fda2b04c9b 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use codex_connectors::AppToolPolicyEvaluator; use codex_connectors::AppToolPolicyInput; -use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo as McpToolInfo; use codex_mcp::tool_is_model_visible; @@ -11,8 +10,6 @@ use tracing::instrument; use crate::config::Config; use crate::connectors; -pub(crate) const DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD: usize = 100; - pub(crate) struct McpToolExposure { pub(crate) direct_tools: Vec, pub(crate) deferred_tools: Option>, @@ -34,13 +31,7 @@ pub(crate) fn build_mcp_tool_exposure( )); } - let should_defer = search_tool_enabled - && (config - .features - .enabled(Feature::ToolSearchAlwaysDeferMcpTools) - || deferred_tools.len() >= DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD); - - if !should_defer { + if !search_tool_enabled { return McpToolExposure { direct_tools: deferred_tools, deferred_tools: None, diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 63a2e33f31..71c92b5580 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use std::sync::Arc; -use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; use codex_tools::ToolName; @@ -95,12 +94,12 @@ fn with_visibility(mut tool: ToolInfo, visibility: &[&str]) -> ToolInfo { } #[tokio::test] -async fn directly_exposes_small_effective_tool_sets() { +async fn directly_exposes_effective_tool_sets_when_search_is_unavailable() { let config = test_config().await; - let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1); + let mcp_tools = numbered_mcp_tools(/*count*/ 2); let exposure = build_mcp_tool_exposure( - &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, + &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ false, ); assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools)); @@ -237,9 +236,9 @@ enabled = true } #[tokio::test] -async fn searches_large_effective_tool_sets() { +async fn defers_effective_tool_sets_when_search_is_available() { let config = test_config().await; - let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD); + let mcp_tools = numbered_mcp_tools(/*count*/ 2); let exposure = build_mcp_tool_exposure( &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, @@ -249,17 +248,13 @@ async fn searches_large_effective_tool_sets() { let deferred_tools = exposure .deferred_tools .as_ref() - .expect("large tool sets should be discoverable through tool_search"); + .expect("MCP tools should be discoverable through tool_search"); assert_eq!(tool_names(deferred_tools), tool_names(&mcp_tools)); } #[tokio::test] -async fn always_defer_feature_defers_apps_too() { - let mut config = test_config().await; - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); +async fn defers_apps_and_non_app_mcp_tools() { + let config = test_config().await; let mcp_tools = vec![ make_mcp_tool( "rmcp", diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 4cf8ab9d8f..57f06317a1 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -43,6 +43,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index bd9289b770..307115c505 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -81,7 +81,7 @@ pub(crate) async fn build_prompt_input_from_session( .await; if !input.is_empty() { - let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input); + let response_item = sess.response_item_from_user_input(input); sess.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&response_item)) .await; } diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 352d6ae8e4..e74d5b3e06 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -78,7 +78,7 @@ fn message(role: &str, content: ContentItem) -> ResponseItem { role: role.to_string(), content: vec![content], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 6e86ed8eb3..1852c4bea6 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -10,6 +10,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_string::to_ascii_json_string; use http::HeaderMap as ApiHeaderMap; use http::HeaderValue; @@ -34,6 +35,7 @@ pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; pub(crate) const PARENT_THREAD_ID_KEY: &str = "parent_thread_id"; pub(crate) const SUBAGENT_KIND_KEY: &str = "subagent_kind"; +pub(crate) const THREAD_SOURCE_KEY: &str = "thread_source"; pub(crate) const SANDBOX_KEY: &str = "sandbox"; pub(crate) const WORKSPACES_KEY: &str = "workspaces"; @@ -56,6 +58,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ FORKED_FROM_THREAD_ID_KEY, PARENT_THREAD_ID_KEY, SUBAGENT_KIND_KEY, + THREAD_SOURCE_KEY, SANDBOX_KEY, WORKSPACES_KEY, ]; @@ -143,6 +146,7 @@ pub struct CodexResponsesMetadata { pub(crate) parent_thread_id: Option, pub(crate) subagent_header: Option, pub(crate) subagent_kind: Option, + pub(crate) thread_source: Option, pub(crate) sandbox: Option, pub(crate) workspaces: BTreeMap, pub(crate) turn_started_at_unix_ms: Option, @@ -167,6 +171,7 @@ impl CodexResponsesMetadata { parent_thread_id: None, subagent_header: None, subagent_kind: None, + thread_source: None, sandbox: None, workspaces: BTreeMap::new(), turn_started_at_unix_ms: None, @@ -268,6 +273,7 @@ impl CodexResponsesMetadata { forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, subagent_kind: self.subagent_kind.as_deref(), + thread_source: self.thread_source.as_ref(), sandbox: self.sandbox.as_deref(), workspaces: non_empty_workspaces(&self.workspaces), turn_started_at_unix_ms: self.turn_started_at_unix_ms, @@ -354,6 +360,8 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] subagent_kind: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] + thread_source: Option<&'a ThreadSource>, + #[serde(default, skip_serializing_if = "Option::is_none")] sandbox: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] workspaces: Option<&'a BTreeMap>, diff --git a/codex-rs/core/src/rollout_budget.rs b/codex-rs/core/src/rollout_budget.rs index b8901a9e36..334e9e73a5 100644 --- a/codex-rs/core/src/rollout_budget.rs +++ b/codex-rs/core/src/rollout_budget.rs @@ -57,18 +57,22 @@ impl RolloutBudget { window_id: &str, ) -> Option { let state = self.lock()?; - let reminder_index = (state.weighted_tokens_used - / state.config.reminder_interval_tokens as f64) + let remaining_tokens = (state.config.limit_tokens as f64 - state.weighted_tokens_used) + .max(0.0) .floor() as i64; + let reminder_index = state + .config + .reminder_at_remaining_tokens + .iter() + .filter(|&&threshold| remaining_tokens <= threshold) + .count() as i64; if state.deliveries.get(&thread_id).is_some_and(|delivery| { delivery.window_id.as_str() == window_id && delivery.reminder_index >= reminder_index }) { return None; } Some(RolloutBudgetReminder { - remaining_tokens: (state.config.limit_tokens as f64 - state.weighted_tokens_used) - .max(0.0) - .floor() as i64, + remaining_tokens, reminder_index, }) } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index c156b0d70e..d0006f8657 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -14,6 +14,7 @@ use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -60,6 +61,8 @@ pub struct ExecRequest { pub network_sandbox_policy: NetworkSandboxPolicy, pub(crate) windows_sandbox_filesystem_overrides: Option, pub arg0: Option, + pub(crate) exec_server_sandbox: Option, + pub(crate) exec_server_enforce_managed_network: bool, } impl ExecRequest { @@ -102,6 +105,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } @@ -158,6 +163,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } } diff --git a/codex-rs/core/src/session/code_mode_warning.rs b/codex-rs/core/src/session/code_mode_warning.rs new file mode 100644 index 0000000000..87f122b065 --- /dev/null +++ b/codex-rs/core/src/session/code_mode_warning.rs @@ -0,0 +1,26 @@ +use codex_features::Feature; +use codex_features::Features; +use codex_protocol::openai_models::ModelInfo; + +pub(super) fn unsupported_code_mode_warning( + model_info: &ModelInfo, + features: &Features, +) -> Option { + let code_mode_enabled = + features.enabled(Feature::CodeMode) || features.enabled(Feature::CodeModeOnly); + if !code_mode_enabled + || model_info.tool_mode.is_some() + || model_info.used_fallback_model_metadata + { + return None; + } + + let model = &model_info.slug; + Some(format!( + "Code Mode is enabled in configuration, but model `{model}` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode." + )) +} + +#[cfg(test)] +#[path = "code_mode_warning_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/session/code_mode_warning_tests.rs b/codex-rs/core/src/session/code_mode_warning_tests.rs new file mode 100644 index 0000000000..ed393d870e --- /dev/null +++ b/codex-rs/core/src/session/code_mode_warning_tests.rs @@ -0,0 +1,70 @@ +use super::unsupported_code_mode_warning; +use codex_features::Feature; +use codex_features::Features; +use codex_models_manager::model_info::model_info_from_slug; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ToolMode; +use pretty_assertions::assert_eq; + +const MODEL_SLUG: &str = "test-model"; + +fn known_model_info() -> ModelInfo { + ModelInfo { + used_fallback_model_metadata: false, + ..model_info_from_slug(MODEL_SLUG) + } +} + +#[test] +fn warns_when_code_mode_is_enabled_without_model_selector() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + + assert_eq!( + unsupported_code_mode_warning(&known_model_info(), &features), + Some(format!( + "Code Mode is enabled in configuration, but model `{MODEL_SLUG}` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode." + )) + ); +} + +#[test] +fn warns_when_code_mode_only_is_enabled_without_model_selector() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeModeOnly); + + assert!(unsupported_code_mode_warning(&known_model_info(), &features).is_some()); +} + +#[test] +fn does_not_warn_when_code_mode_is_disabled() { + assert_eq!( + unsupported_code_mode_warning(&known_model_info(), &Features::with_defaults()), + None + ); +} + +#[test] +fn does_not_warn_when_model_has_tool_mode_selector() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeModeOnly); + + for tool_mode in [ToolMode::Direct, ToolMode::CodeMode, ToolMode::CodeModeOnly] { + let model_info = ModelInfo { + tool_mode: Some(tool_mode), + ..known_model_info() + }; + assert_eq!(unsupported_code_mode_warning(&model_info, &features), None); + } +} + +#[test] +fn fallback_metadata_only_uses_existing_warning() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + + assert_eq!( + unsupported_code_mode_warning(&model_info_from_slug(MODEL_SLUG), &features), + None + ); +} diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 79afd11515..035f7bce4d 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -10,6 +10,7 @@ use codex_features::FeatureToml; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::RolloutBudgetConfigToml; +use codex_features::TokenBudgetConfigToml; use codex_protocol::ThreadId; use crate::config::Config; @@ -152,6 +153,12 @@ fn save_config_resolved_fields( resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + if let Some(token_budget) = config.token_budget.as_ref() { + let mut token_budget: TokenBudgetConfigToml = + resolved_config_to_toml(token_budget, "features.token_budget")?; + token_budget.enabled = Some(config.features.enabled(Feature::TokenBudget)); + features.token_budget = Some(FeatureToml::Config(token_budget)); + } if let Some(rollout_budget) = config.rollout_budget.as_ref() { let mut rollout_budget: RolloutBudgetConfigToml = resolved_config_to_toml(rollout_budget, "features.rollout_budget")?; @@ -238,9 +245,17 @@ mod tests { async fn lock_contains_prompts_and_materializes_features() { let mut sc = crate::session::tests::make_session_configuration_for_tests().await; let mut config = (*sc.original_config_do_not_use).clone(); + config.token_budget = Some(crate::config::TokenBudgetConfig { + reminder_threshold_tokens: Some(16_000), + reminder_message_template: "Locked reminder: {n_remaining} tokens.".to_string(), + }); + config + .features + .enable(Feature::TokenBudget) + .expect("token_budget should be enableable in tests"); config.rollout_budget = Some(crate::config::RolloutBudgetConfig { limit_tokens: 100_000, - reminder_interval_tokens: 10_000, + reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, prefill_token_weight: 0.25, }); @@ -312,18 +327,28 @@ mod tests { min_wait_timeout_ms: Some(_), max_wait_timeout_ms: Some(_), default_wait_timeout_ms: Some(_), - usage_hint_enabled: Some(_), hide_spawn_agent_metadata: Some(_), .. }) )); + assert_eq!( + features.token_budget, + Some(FeatureToml::Config(TokenBudgetConfigToml { + enabled: Some(true), + reminder_threshold_tokens: Some(16_000), + reminder_message_template: Some( + "Locked reminder: {n_remaining} tokens.".to_string() + ), + })) + ); + assert_eq!( features.rollout_budget, Some(FeatureToml::Config(RolloutBudgetConfigToml { enabled: Some(true), limit_tokens: Some(100_000), - reminder_interval_tokens: Some(10_000), + reminder_at_remaining_tokens: Some(vec![50_000, 25_000, 10_000]), sampling_token_weight: Some(1.0), prefill_token_weight: Some(0.25), })) diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 2feee3e9f3..39e91d6cf9 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -218,7 +218,7 @@ pub(super) async fn user_input_or_turn_inner( }) .await; } - sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) + sess.maybe_emit_model_warnings_for_turn(current_context.as_ref()) .await; match sess .steer_input( @@ -673,7 +673,7 @@ pub async fn review( 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()) + sess.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) .await; sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer())) .await; diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 3f73cd96d0..5c5b7d1feb 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -94,7 +94,7 @@ impl Session { input, )); } - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + self.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) .await; if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index d03576aadc..42c274fdb4 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -317,8 +317,6 @@ impl Session { let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config); let mcp_servers = effective_mcp_servers_from_configured(mcp_servers, &mcp_config, auth.as_ref()); - let host_owned_codex_apps_enabled = - host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), store_mode, @@ -359,7 +357,6 @@ impl Session { mcp_runtime_context, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), - host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, self.services diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b3ed8619de..4b87a0cc9d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,6 +32,7 @@ use crate::context::NetworkRuleSaved; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; use crate::context::RecommendedPluginsInstructions; +use crate::context::world_state::WorldState; use crate::current_time::TimeProvider; use crate::default_skill_metadata_budget; use crate::environment_selection::TurnEnvironmentSnapshot; @@ -207,6 +208,7 @@ use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::exec_output::StreamOutput; +mod code_mode_warning; mod config_lock; mod handlers; mod inject; @@ -218,10 +220,13 @@ mod rollout_budget; mod rollout_reconstruction; #[allow(clippy::module_inception)] pub(crate) mod session; +pub(crate) mod step_context; pub(crate) mod time_reminder; mod token_budget; pub(crate) mod turn; pub(crate) mod turn_context; +mod world_state; +use self::code_mode_warning::unsupported_code_mode_warning; use self::config_lock::export_config_lock_if_configured; use self::config_lock::validate_config_lock_if_configured; #[cfg(test)] @@ -242,6 +247,9 @@ use self::turn::collect_explicit_app_ids_from_skill_items; use self::turn::realtime_text_for_event; use self::turn_context::TurnContext; use self::turn_context::TurnSkillsContext; +use self::world_state::build_world_state_from_environment_snapshot; +use self::world_state::build_world_state_from_turn_context; +use self::world_state::build_world_state_from_turn_context_item; #[cfg(test)] mod rollout_reconstruction_tests; @@ -311,6 +319,7 @@ use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; #[cfg(test)] use crate::skills::SkillLoadOutcome; +use crate::state::AutoCompactWindowIds; use crate::state::AutoCompactWindowSnapshot; use crate::state::PendingRequestPermissions; use crate::state::SessionServices; @@ -336,7 +345,6 @@ use codex_git_utils::get_git_repo_root; use codex_mcp::McpConfig; use codex_mcp::compute_auth_statuses; use codex_mcp::effective_mcp_servers_from_configured; -use codex_mcp::host_owned_codex_apps_enabled; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; use codex_otel::TelemetryAuthMode; @@ -593,7 +601,7 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); - let multi_agent_mode = initial_multi_agent_mode; + let multi_agent_mode = initial_multi_agent_mode.unwrap_or_default(); config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; @@ -1387,26 +1395,36 @@ impl Session { previous_turn_settings, reference_context_item, window_number, + first_window_id, + previous_window_id, window_id, } = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - // Keep the recorded rollout unchanged. Prepare its reconstructed history before - // installing it, so legacy images are processed once for this resume or fork and - // will be processed again if the rollout is reconstructed in a future session. - // This meets image resizing requirements without modifying persisted rollouts. - prepare_response_items(&mut history); - } + // Keep the recorded rollout unchanged. Prepare its reconstructed history before + // installing it, so legacy images are processed once for this resume or fork and + // will be processed again if the rollout is reconstructed in a future session. + // This meets image resizing requirements without modifying persisted rollouts. + prepare_response_items(&mut history); + let world_state_baseline = reference_context_item + .as_ref() + .map(build_world_state_from_turn_context_item); { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); - let window_id = window_id.unwrap_or_else(|| state.auto_compact_window_id()); - state.restore_auto_compact_window(window_number, window_id); + if let Some(world_state) = world_state_baseline { + state.history.set_world_state_baseline(world_state); + } + let fallback_ids = state.auto_compact_window_ids(); + let window_id = window_id.unwrap_or(fallback_ids.window_id); + state.restore_auto_compact_window( + window_number, + AutoCompactWindowIds { + first_window_id: first_window_id.unwrap_or(window_id), + previous_window_id, + window_id, + }, + ); state.set_previous_turn_settings(previous_turn_settings.clone()); } let prefix_tokens = if matches!( @@ -1691,19 +1709,17 @@ impl Session { ) -> Vec { // 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, previous-turn bridge) should be persisted + // context (exec policy, feature gates, previous-turn bridge) should be persisted // state or explicit non-state replay events. let previous_turn_settings = { let state = self.state.lock().await; state.previous_turn_settings() }; - let shell = self.user_shell(); let exec_policy = self.services.exec_policy.current(); crate::context_manager::updates::build_settings_update_items( reference_context_item, previous_turn_settings.as_ref(), current_context, - shell.as_ref(), exec_policy.as_ref(), self.features.enabled(Feature::Personality), ) @@ -2700,12 +2716,10 @@ impl Session { items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { let mut items = Cow::Borrowed(items); - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - prepare_response_items(items.to_mut()); + prepare_response_items(items.to_mut()); + // Most response items get their passthrough turn ID at the durable history boundary. + for item in items.to_mut() { + item.set_turn_id_if_missing(&turn_context.sub_id); } if turn_context.config.features.enabled(Feature::ItemIds) { Self::assign_missing_response_item_ids(items) @@ -2724,6 +2738,7 @@ impl Session { continue; } let prefix = match item { + ResponseItem::AdditionalTools { .. } => "at", ResponseItem::Message { .. } => "msg", ResponseItem::Reasoning { .. } => "rs", ResponseItem::LocalShellCall { .. } => "lsh", @@ -2745,23 +2760,10 @@ impl Session { items } - pub(crate) fn response_item_from_user_input( - &self, - turn_context: &TurnContext, - input: Vec, - ) -> ResponseItem { - let local_image_preparation = if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - LocalImagePreparation::Defer - } else { - LocalImagePreparation::Process - }; + pub(crate) fn response_item_from_user_input(&self, input: Vec) -> ResponseItem { ResponseItem::from(ResponseInputItem::from_user_input( input, - local_image_preparation, + LocalImagePreparation::Defer, )) } @@ -2784,11 +2786,36 @@ impl Session { self.send_raw_response_items(turn_context, items).await; } + pub(crate) async fn record_step_environment_context_if_changed( + &self, + turn_context: &TurnContext, + step_context: &step_context::StepContext, + ) { + if !turn_context.config.include_environment_context { + return; + } + + let world_state = + build_world_state_from_environment_snapshot(turn_context, &step_context.environments); + let items = { + let mut state = self.state.lock().await; + crate::context_manager::updates::merge_contextual_fragments( + state.history.update_world_state(world_state), + ) + }; + if items.is_empty() { + return; + } + + self.record_conversation_items(turn_context, &items).await; + } + pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, - communication: InterAgentCommunication, + mut communication: InterAgentCommunication, ) { + communication.set_turn_id_if_missing(&turn_context.sub_id); let response_item = communication.to_model_input_item(); let items = self.prepare_conversation_items_for_history( turn_context, @@ -2893,9 +2920,17 @@ impl Session { replacement_history: Some(items.clone()), ..compacted_item }; + let world_state_baseline = if reference_context_item.is_some() { + Some(self.build_world_state(turn_context).await) + } else { + None + }; { let mut state = self.state.lock().await; state.replace_history(items, reference_context_item.clone()); + if let Some(world_state) = world_state_baseline { + state.history.set_world_state_baseline(world_state); + } } self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)]) @@ -3024,6 +3059,28 @@ impl Session { pub(crate) async fn build_initial_context( &self, turn_context: &TurnContext, + ) -> Vec { + let world_state = self.build_world_state(turn_context).await; + self.build_initial_context_with_world_state(turn_context, &world_state) + .await + } + + async fn build_world_state(&self, turn_context: &TurnContext) -> WorldState { + let environment_subagents = if turn_context.config.include_environment_context { + self.services + .agent_control + .format_environment_context_subagents(self.thread_id) + .await + } else { + String::new() + }; + build_world_state_from_turn_context(turn_context, &environment_subagents) + } + + async fn build_initial_context_with_world_state( + &self, + turn_context: &TurnContext, + world_state: &WorldState, ) -> Vec { let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); @@ -3034,7 +3091,7 @@ impl Session { collaboration_mode, base_instructions, session_source, - auto_compact_window_id, + auto_compact_window_ids, ) = { let state = self.state.lock().await; ( @@ -3043,7 +3100,7 @@ impl Session { state.session_configuration.collaboration_mode.clone(), state.session_configuration.base_instructions.clone(), state.session_configuration.session_source.clone(), - state.auto_compact_window_id(), + state.auto_compact_window_ids(), ) }; if let Some(model_switch_message) = @@ -3228,29 +3285,48 @@ impl Session { } // This is full-context metadata. Steady-state context diffs should not re-emit it. if turn_context.config.features.enabled(Feature::TokenBudget) - && let Some(model_context_window) = turn_context.model_context_window() + && turn_context.model_context_window().is_some() { + let mcp_result = self + .call_tool( + "notes", + "thread_hint", + /*arguments*/ None, + Some(serde_json::json!({ + "threadId": self.thread_id().to_string(), + })), + ) + .await + .ok() + .and_then(|result| { + let text = result + .content + .iter() + .filter_map(|content| { + content.get("text").and_then(serde_json::Value::as_str) + }) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) + }); developer_sections.push( crate::context::TokenBudgetContext::new( self.thread_id(), - auto_compact_window_id, - model_context_window, + auto_compact_window_ids.first_window_id, + auto_compact_window_ids.previous_window_id, + auto_compact_window_ids.window_id, + mcp_result, ) .render(), ); } - if turn_context.config.include_environment_context { - let shell = self.user_shell(); - let subagents = self - .services - .agent_control - .format_environment_context_subagents(self.thread_id) - .await; - contextual_user_sections.push( - crate::context::EnvironmentContext::from_turn_context(turn_context, shell.as_ref()) - .with_subagents(subagents) - .render(), - ); + for fragment in world_state.render_full() { + match fragment.role() { + "developer" => developer_sections.push(fragment.render()), + "user" => contextual_user_sections.push(fragment.render()), + _ => {} + } } let multi_agent_v2_usage_hint_text = @@ -3277,19 +3353,20 @@ impl Session { { items.push(usage_hint_message); } - if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode( + match multi_agents::effective_multi_agent_mode( turn_context.multi_agent_version, - &turn_context.config.multi_agent_v2, &session_source, turn_context.multi_agent_mode, - turn_context - .config - .features - .enabled(Feature::MultiAgentMode), ) { - items.push(ContextualUserFragment::into( - MultiAgentModeInstructions::new(multi_agent_mode), - )); + Some( + multi_agent_mode + @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive), + ) => { + items.push(ContextualUserFragment::into( + MultiAgentModeInstructions::new(multi_agent_mode), + )); + } + Some(MultiAgentMode::None) | None => {} } if let Some(contextual_user_message) = crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) @@ -3308,6 +3385,10 @@ impl Session { { items.push(guardian_developer_message); } + // New context windows and compaction install these items directly into replacement history. + for item in &mut items { + item.set_turn_id_if_missing(&turn_context.sub_id); + } items } @@ -3332,10 +3413,9 @@ impl Session { format!("{thread_id}:{window_number}") } - pub(crate) async fn advance_auto_compact_window(&self) -> (u64, String) { + pub(crate) async fn advance_auto_compact_window(&self) -> (u64, AutoCompactWindowIds) { let mut state = self.state.lock().await; - let (window_number, window_id) = state.advance_auto_compact_window(); - (window_number, window_id.to_string()) + state.advance_auto_compact_window() } pub(crate) async fn request_new_context_window(&self) { @@ -3351,20 +3431,26 @@ impl Session { let mut state = self.state.lock().await; state.start_new_context_window_if_requested() }; - let (window_number, window_id) = window?; - let context_items = self.build_initial_context(turn_context).await; + let (window_number, window_ids) = window?; + let world_state = self.build_world_state(turn_context).await; + let context_items = self + .build_initial_context_with_world_state(turn_context, &world_state) + .await; let turn_context_item = turn_context.to_turn_context_item(); let replacement_history = context_items; { let mut state = self.state.lock().await; state.replace_history(replacement_history.clone(), Some(turn_context_item.clone())); + state.history.set_world_state_baseline(world_state); }; self.persist_rollout_items(&[ RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history), window_number: Some(window_number), - window_id: Some(window_id.to_string()), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), }), RolloutItem::TurnContext(turn_context_item), ]) @@ -3386,15 +3472,15 @@ impl Session { /// steady-state turns that emit model-visible context updates. /// /// When the reference snapshot is missing, this injects full initial context. Otherwise, it - /// emits only settings diff items. + /// emits only context diffs. /// /// If full context is injected and a model switch occurred, this prepends the /// `` developer message so model-specific instructions are not lost. /// /// This is the normal runtime path that establishes a new `reference_context_item`. - /// Mid-turn compaction is the other path that can re-establish that baseline when it - /// reinjects full initial context into replacement history. Other non-regular tasks - /// intentionally do not update the baseline. + /// Mid-turn compaction is the other path that can re-establish that reference when it + /// reinjects full initial context into replacement history. Live world-state changes may + /// independently advance their in-memory baseline within a turn. #[instrument(level = "trace", skip_all)] pub(crate) async fn record_context_updates_and_set_reference_context_item( &self, @@ -3405,24 +3491,43 @@ impl Session { state.reference_context_item() }; let turn_context_item = turn_context.to_turn_context_item(); - if reference_context_item.as_ref() == Some(&turn_context_item) { - return; - } + let turn_context_changed = reference_context_item.as_ref() != Some(&turn_context_item); let should_inject_full_context = reference_context_item.is_none(); + let world_state = self.build_world_state(turn_context).await; let mut context_items = if should_inject_full_context { - self.build_initial_context(turn_context).await + let context_items = self + .build_initial_context_with_world_state(turn_context, &world_state) + .await; + self.state + .lock() + .await + .history + .set_world_state_baseline(world_state); + context_items } else { // Steady-state path: append only built-in context diffs here; turn-scoped extension // context is added below. - self.build_settings_update_items(reference_context_item.as_ref(), turn_context) - .await + let mut context_items = self + .build_settings_update_items(reference_context_item.as_ref(), turn_context) + .await; + let world_state_items = { + let mut state = self.state.lock().await; + crate::context_manager::updates::merge_contextual_fragments( + state.history.update_world_state(world_state), + ) + }; + context_items.extend(world_state_items); + context_items }; - if !should_inject_full_context { + if !should_inject_full_context && turn_context_changed { context_items.extend( self.build_turn_context_contribution_items(turn_context) .await, ); } + if !turn_context_changed && context_items.is_empty() { + return; + } if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; @@ -3432,8 +3537,8 @@ impl Session { self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item.clone())]) .await; - // Advance the in-memory diff baseline even when this turn emitted no model-visible - // context items. This keeps later runtime diffing aligned with the current turn state. + // Advance the persisted-settings baseline even when this turn emitted no model-visible + // context items. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); } @@ -3600,7 +3705,7 @@ impl Session { // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry // those spans, and `record_response_item_and_emit_turn_item` would drop them. - let response_item = self.response_item_from_user_input(turn_context, input.to_vec()); + let response_item = self.response_item_from_user_input(input.to_vec()); self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; let mut user_message_item = UserMessageItem::new(input); diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 38651380eb..82fcfd1282 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -14,10 +14,6 @@ pub(super) fn usage_hint_text<'a>( } let multi_agent_v2 = &turn_context.config.multi_agent_v2; - if !multi_agent_v2.usage_hint_enabled { - return None; - } - configured_usage_hint_text_for_source(multi_agent_v2, session_source) } @@ -39,30 +35,23 @@ fn configured_usage_hint_text_for_source<'a>( } } -fn multi_agent_mode_is_applicable( - multi_agent_version: MultiAgentVersion, - multi_agent_v2: &MultiAgentV2Config, - session_source: &SessionSource, -) -> bool { - multi_agent_version == MultiAgentVersion::V2 - && multi_agent_v2.usage_hint_enabled - && configured_usage_hint_text_for_source(multi_agent_v2, session_source).is_some() -} - pub(crate) fn effective_multi_agent_mode( multi_agent_version: MultiAgentVersion, - multi_agent_v2: &MultiAgentV2Config, session_source: &SessionSource, - requested_multi_agent_mode: Option, - multi_agent_mode_enabled: bool, + multi_agent_mode: MultiAgentMode, ) -> Option { - if !multi_agent_mode_is_applicable(multi_agent_version, multi_agent_v2, session_source) { + if multi_agent_version != MultiAgentVersion::V2 { return None; } - Some(if multi_agent_mode_enabled { - requested_multi_agent_mode.unwrap_or_default() - } else { - MultiAgentMode::ExplicitRequestOnly - }) + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) + | SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => Some(multi_agent_mode), + SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, + } } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 7fccc4d19f..8986ffd052 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -75,9 +75,12 @@ pub(super) async fn spawn_review_thread( .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); let session_source = parent_turn_context.session_source.clone(); - let forked_from_thread_id = { + let (forked_from_thread_id, thread_source) = { let state = sess.state.lock().await; - state.session_configuration.forked_from_thread_id + ( + state.session_configuration.forked_from_thread_id, + state.session_configuration.thread_source.clone(), + ) }; let per_turn_config = Arc::new(per_turn_config); @@ -88,6 +91,7 @@ pub(super) async fn spawn_review_thread( forked_from_thread_id, parent_turn_context.parent_thread_id, &session_source, + thread_source, review_turn_id.clone(), #[allow(deprecated)] parent_turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 22d0990e32..6989ff80d0 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -10,12 +10,16 @@ pub(super) struct RolloutReconstruction { pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, pub(super) window_number: u64, + pub(super) first_window_id: Option, + pub(super) previous_window_id: Option, pub(super) window_id: Option, } #[derive(Debug, Clone, Copy)] struct ReconstructedWindow { number: u64, + first_id: Option, + previous_id: Option, id: Option, } @@ -133,6 +137,11 @@ impl Session { { active_segment.window = Some(ReconstructedWindow { number: window_number, + first_id: compacted.first_window_id.as_deref().and_then(parse_uuid_v7), + previous_id: compacted + .previous_window_id + .as_deref() + .and_then(parse_uuid_v7), id: compacted.window_id.as_deref().and_then(parse_uuid_v7), }); } @@ -341,13 +350,17 @@ impl Session { let window = window.unwrap_or(ReconstructedWindow { number: fallback_window_number, + first_id: None, + previous_id: None, id: None, }); RolloutReconstruction { - history: history.raw_items().to_vec(), + history: history.into_raw_items(), previous_turn_settings, reference_context_item, window_number: window.number, + first_window_id: window.first_id, + previous_window_id: window.previous_id, window_id: window.id, } } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 43563d2339..828c078586 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -20,7 +20,7 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -32,7 +32,7 @@ fn assistant_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -51,7 +51,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { text: serde_json::to_string(&communication).unwrap(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -69,7 +69,9 @@ async fn record_initial_history_reconstructs_typed_inter_agent_message() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![RolloutItem::InterAgentCommunication(communication.clone())], + history: Arc::new(vec![RolloutItem::InterAgentCommunication( + communication.clone(), + )]), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -112,7 +114,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -189,7 +191,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -757,7 +759,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -829,6 +831,8 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -839,7 +843,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -869,7 +873,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_seed_referenc session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -887,6 +891,8 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -894,7 +900,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -914,6 +920,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_ message: "legacy summary".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -947,6 +955,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear message: "legacy summary".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1043,6 +1053,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::TurnContext(previous_context_item), @@ -1060,7 +1072,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1196,6 +1208,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1203,7 +1217,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1326,7 +1340,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1433,6 +1447,8 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1440,7 +1456,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1491,7 +1507,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1599,6 +1615,8 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), // A newer TurnStarted replaces the incomplete compacted turn without a matching @@ -1617,7 +1635,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 245c72c6e3..8c00fbf5aa 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -52,7 +52,7 @@ pub(crate) struct SessionConfiguration { pub(super) provider: ModelProviderInfo, pub(super) collaboration_mode: CollaborationMode, - pub(super) multi_agent_mode: Option, + pub(super) multi_agent_mode: MultiAgentMode, pub(super) model_reasoning_summary: Option, pub(super) service_tier: Option, @@ -233,7 +233,7 @@ impl SessionConfiguration { next_configuration.collaboration_mode = collaboration_mode; } if let Some(multi_agent_mode) = updates.multi_agent_mode { - next_configuration.multi_agent_mode = Some(multi_agent_mode); + next_configuration.multi_agent_mode = multi_agent_mode; } if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); @@ -527,6 +527,33 @@ impl Session { } InitialHistory::Resumed(resumed_history) => resumed_history.conversation_id, }; + let resumed_session_id = match &initial_history { + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.session_id), + _ => None, + }) + } + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, + }; + // Legacy subagent rollouts synthesize session_id from their own thread id. + let resumed_session_id = resumed_session_id.filter(|session_id| { + !session_configuration.session_source.is_non_root_agent() + || *session_id != SessionId::from(thread_id) + }); + let session_id = resumed_session_id.unwrap_or_else(|| { + if session_configuration.session_source.is_non_root_agent() { + agent_control.session_id() + } else { + SessionId::from(thread_id) + } + }); + let agent_control = agent_control.with_session_id( + session_id, + config + .effective_agent_max_threads(MultiAgentVersion::V2) + .unwrap_or(usize::MAX), + ); let time_provider = crate::current_time::resolve_time_provider( config.current_time_reminder.as_ref(), external_time_provider, @@ -558,6 +585,7 @@ impl Session { let live_thread = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { let params = CreateThreadParams { + session_id, thread_id, extra_config: config.extra_config.clone(), forked_from_id, @@ -985,17 +1013,6 @@ impl Session { config.analytics_enabled, ) }); - let session_id = if session_configuration.session_source.is_non_root_agent() { - agent_control.session_id() - } else { - SessionId::from(thread_id) - }; - let agent_control = agent_control.with_session_id( - session_id, - config - .effective_agent_max_threads(MultiAgentVersion::V2) - .unwrap_or(usize::MAX), - ); // Keep one stable manager handle for the session so extension resource clients // automatically observe the manager installed at startup and on later refreshes. let mcp_connection_manager = Arc::new(arc_swap::ArcSwap::from_pointee( @@ -1154,9 +1171,6 @@ impl Session { sess.send_event_raw(event).await; } - let host_owned_codex_apps_enabled = config - .features - .apps_enabled_for_auth(auth.as_ref().is_some_and(|auth| auth.uses_codex_backend())); let client_elicitation_capability = if config.features.enabled(Feature::AuthElicitation) { ElicitationCapability { form: Some(FormElicitationCapability::default()), @@ -1199,7 +1213,6 @@ impl Session { mcp_runtime_context, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth), - host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, sess.services @@ -1233,7 +1246,6 @@ impl Session { let mut state = sess.state.lock().await; state.queue_pending_session_start_source(session_start_source); } - Ok(sess) } .await; diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs new file mode 100644 index 0000000000..17243ced67 --- /dev/null +++ b/codex-rs/core/src/session/step_context.rs @@ -0,0 +1,7 @@ +use crate::environment_selection::TurnEnvironmentSnapshot; + +/// Request-scoped state that may change between model sampling requests. +#[derive(Debug)] +pub(crate) struct StepContext { + pub(crate) environments: TurnEnvironmentSnapshot, +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a1fe0b18c7..9e7d35b481 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -107,8 +107,8 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ResponseItem; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::CompactedItem; @@ -152,6 +152,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_items; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; @@ -196,7 +197,7 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -210,7 +211,7 @@ fn assign_missing_response_item_ids_skips_agent_messages() { content: vec![AgentMessageInputContent::InputText { text: "done".to_string(), }], - metadata: None, + internal_chat_message_metadata_passthrough: None, }, user_message("hello"), ]); @@ -221,6 +222,19 @@ fn assign_missing_response_item_ids_skips_agent_messages() { assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); } +#[test] +fn assign_missing_response_item_ids_assigns_additional_tools_ids() { + let items = Cow::Owned(vec![ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: Vec::new(), + }]); + + let items = Session::assign_missing_response_item_ids(items); + + assert!(items[0].id().is_some_and(|id| id.starts_with("at_"))); +} + fn assistant_message(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -229,7 +243,7 @@ fn assistant_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -289,7 +303,7 @@ fn skill_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -1636,9 +1650,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { text: "summary".to_string(), }], phase: None, - metadata: Some(ResponseItemMetadata { + internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { turn_id: Some("compact-turn".to_string()), - ..Default::default() }), }; let replacement_history = vec![ @@ -1650,14 +1663,18 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { text: "stale developer instructions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; + let first_window_id = Uuid::now_v7(); + let previous_window_id = Uuid::now_v7(); let window_id = Uuid::now_v7(); let rollout_items = vec![RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history.clone()), window_number: Some(42), + first_window_id: Some(first_window_id.to_string()), + previous_window_id: Some(previous_window_id.to_string()), window_id: Some(window_id.to_string()), })]; @@ -1667,6 +1684,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { assert_eq!(reconstructed.history, replacement_history); assert_eq!(42, reconstructed.window_number); + assert_eq!(Some(first_window_id), reconstructed.first_window_id); + assert_eq!(Some(previous_window_id), reconstructed.previous_window_id); assert_eq!(Some(window_id), reconstructed.window_id); } @@ -1678,7 +1697,7 @@ async fn record_initial_history_reconstructs_resumed_transcript() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1688,12 +1707,72 @@ async fn record_initial_history_reconstructs_resumed_transcript() { } #[tokio::test] -async fn resize_all_images_prepares_failures_before_history_insertion() { +async fn record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id() { + let (session, turn_context) = make_session_and_context().await; + let fresh_item = user_message("fresh"); + let mut existing_item = assistant_message("existing"); + existing_item.set_turn_id_if_missing("older-turn"); + + session + .record_conversation_items(&turn_context, &[fresh_item.clone(), existing_item.clone()]) + .await; + + let mut expected_fresh_item = fresh_item; + expected_fresh_item.set_turn_id_if_missing(&turn_context.sub_id); + let expected_items = vec![expected_fresh_item, existing_item]; + assert_eq!( + session.clone_history().await.raw_items(), + expected_items.as_slice() + ); +} + +#[tokio::test] +async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { + let (mut session, turn_context) = make_session_and_context().await; + let rollout_path = attach_thread_persistence(&mut session).await; + let communication = InterAgentCommunication::new( + AgentPath::root().join("worker").expect("worker path"), + AgentPath::root(), + Vec::new(), + "child done".to_string(), + /*trigger_turn*/ false, + ); + let mut expected_item = communication.to_model_input_item(); + expected_item.set_turn_id_if_missing(&turn_context.sub_id); + + session + .record_inter_agent_communication(&turn_context, communication) + .await; + + assert_eq!( + session.clone_history().await.raw_items(), + std::slice::from_ref(&expected_item) + ); + + session.flush_rollout().await.expect("rollout should flush"); + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + + let (resumed_session, _resumed_turn_context) = make_session_and_context().await; + resumed_session + .record_initial_history(InitialHistory::Resumed(resumed)) + .await; + assert_eq!( + resumed_session.clone_history().await.raw_items(), + std::slice::from_ref(&expected_item) + ); +} + +#[tokio::test] +async fn prepares_image_failures_before_history_insertion() { let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { - let _ = config.features.enable(Feature::ResizeAllImages); let _ = config.features.enable(Feature::ItemIds); }, ) @@ -1717,7 +1796,7 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session @@ -1745,28 +1824,21 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { FunctionCallOutputContentItem::InputText { text: "image content omitted because it could not be processed".to_string(), }, - FunctionCallOutputContentItem::InputImage { - image_url: "https://example.com/image.png".to_string(), - detail: Some(ImageDetail::High), + FunctionCallOutputContentItem::InputText { + text: "image content omitted because remote image URLs are not supported" + .to_string(), }, ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); } #[tokio::test] -async fn resize_all_images_prepares_resumed_history_before_installing_it() { - let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( - CodexAuth::from_api_key("Test API Key"), - Vec::new(), - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, - ) - .await; +async fn prepares_resumed_history_before_installing_it() { + let (session, _turn_context) = make_session_and_context().await; let resumed_item = ResponseItem::Message { id: None, role: "user".to_string(), @@ -1775,18 +1847,22 @@ async fn resize_all_images_prepares_resumed_history_before_installing_it() { image_url: "data:image/png;base64,%%%".to_string(), detail: Some(ImageDetail::High), }, + ContentItem::InputImage { + image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::High), + }, ContentItem::InputText { text: "keep me".to_string(), }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![RolloutItem::ResponseItem(resumed_item)], + history: Arc::new(vec![RolloutItem::ResponseItem(resumed_item)]), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1800,12 +1876,16 @@ async fn resize_all_images_prepares_resumed_history_before_installing_it() { ContentItem::InputText { text: "image content omitted because it could not be processed".to_string(), }, + ContentItem::InputText { + text: "image content omitted because remote image URLs are not supported" + .to_string(), + }, ContentItem::InputText { text: "keep me".to_string(), }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -1825,7 +1905,7 @@ fn resolve_multi_agent_version_handles_unset_and_legacy_history() { resolve_multi_agent_version( &InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: Arc::new(Vec::new()), rollout_path: None, }), /*inherited_multi_agent_version*/ None, @@ -1836,7 +1916,7 @@ fn resolve_multi_agent_version_handles_unset_and_legacy_history() { resolve_multi_agent_version( &InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: Arc::new(Vec::new()), rollout_path: None, }), Some(MultiAgentVersion::V2), @@ -1847,10 +1927,10 @@ fn resolve_multi_agent_version_handles_unset_and_legacy_history() { resolve_multi_agent_version( &InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: vec![session_meta_item( + history: Arc::new(vec![session_meta_item( thread_id, Some(MultiAgentVersion::Disabled) - )], + )]), rollout_path: None, }), Some(MultiAgentVersion::V2), @@ -1894,6 +1974,7 @@ fn session_meta_item( ) -> RolloutItem { RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version, ..SessionMeta::default() @@ -1910,7 +1991,7 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -2004,7 +2085,7 @@ async fn record_initial_history_seeds_token_info_from_rollout() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -3059,6 +3140,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio user_message("turn 1 user"), user_message("summary after compaction"), ]; + let first_window_id = Uuid::now_v7(); + let previous_window_id = Uuid::now_v7(); let compacted_window_id = Uuid::now_v7(); sess.persist_rollout_items(&[ @@ -3102,6 +3185,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio message: "summary after compaction".to_string(), replacement_history: Some(compacted_history.clone()), window_number: Some(7), + first_window_id: Some(first_window_id.to_string()), + previous_window_id: Some(previous_window_id.to_string()), window_id: Some(compacted_window_id.to_string()), }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { @@ -3152,7 +3237,14 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .await; { let mut state = sess.state.lock().await; - state.restore_auto_compact_window(/*window_number*/ 99, Uuid::now_v7()); + state.restore_auto_compact_window( + /*window_number*/ 99, + AutoCompactWindowIds { + first_window_id: Uuid::now_v7(), + previous_window_id: Some(Uuid::now_v7()), + window_id: Uuid::now_v7(), + }, + ); } handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; @@ -3162,8 +3254,12 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio assert_eq!(sess.clone_history().await.raw_items(), compacted_history); assert!(sess.reference_context_item().await.is_none()); assert_eq!( - sess.state.lock().await.auto_compact_window_id(), - compacted_window_id + sess.state.lock().await.auto_compact_window_ids(), + AutoCompactWindowIds { + first_window_id, + previous_window_id: Some(previous_window_id), + window_id: compacted_window_id, + } ); assert!(sess.current_window_id().await.ends_with(":7")); } @@ -3731,6 +3827,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { let live_thread = LiveThread::create( Arc::clone(&session.services.thread_store), CreateThreadParams { + session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, forked_from_id: None, @@ -5419,7 +5516,7 @@ async fn resumed_root_session_uses_thread_id_as_session_id() { let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: Arc::new(Vec::new()), rollout_path: None, }), SessionSource::Exec, @@ -5440,7 +5537,7 @@ async fn resumed_root_session_uses_thread_id_as_session_id() { } #[tokio::test] -async fn resumed_subagent_session_keeps_inherited_session_id() { +async fn resumed_subagent_session_restores_persisted_session_id() { let parent_thread_id = ThreadId::new(); let parent_session_id = SessionId::from(parent_thread_id); let thread_id = ThreadId::new(); @@ -5454,11 +5551,19 @@ async fn resumed_subagent_session_keeps_inherited_session_id() { let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: Arc::new(vec![RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + session_id: parent_session_id, + id: thread_id, + source: session_source.clone(), + ..SessionMeta::default() + }, + git: None, + })]), rollout_path: None, }), session_source, - AgentControl::default().with_session_id(parent_session_id, /*max_threads*/ usize::MAX), + AgentControl::default(), ) .await .expect("resume should succeed"); @@ -6577,6 +6682,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { let live_thread = LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { + session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, forked_from_id: None, @@ -7336,7 +7442,7 @@ async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_task } #[tokio::test] -async fn build_settings_update_items_emits_environment_item_for_network_changes() { +async fn record_context_updates_emits_environment_item_for_network_changes() { let (session, previous_context) = make_session_and_context().await; let previous_context = Arc::new(previous_context); let mut current_context = previous_context @@ -7383,10 +7489,8 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( .expect("rebuild config layer stack with network requirements"); current_context.config = Arc::new(config); - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; let environment_update = user_input_texts(&update_items) .into_iter() @@ -7398,50 +7502,40 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( } #[tokio::test] -async fn environment_context_uses_session_shell_when_environment_shell_is_absent() { - let (mut session, mut turn_context) = make_session_and_context().await; - session.services.user_shell = Arc::new(crate::shell::Shell { - shell_type: crate::shell::ShellType::PowerShell, - shell_path: PathBuf::from("powershell"), - }); - for environment in &mut turn_context.environments.turn_environments { - environment.shell = None; - } - - let session_shell = session.user_shell(); - let environment_context = crate::context::EnvironmentContext::from_turn_context( - &turn_context, - session_shell.as_ref(), - ) - .render(); - assert!( - environment_context.contains("powershell"), - "{environment_context}" +async fn record_context_updates_emits_environment_item_for_cwd_changes() { + let (session, previous_context) = make_session_and_context().await; + let previous_context = Arc::new(previous_context); + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + let cwd = test_path_buf("/new-repo").abs(); + let environment = current_context.environments.turn_environments[0].clone(); + current_context.environments.turn_environments[0] = TurnEnvironment::new( + environment.environment_id, + environment.environment, + PathUri::from_abs_path(&cwd), + environment.shell, ); - let primary_environment = turn_context - .environments - .turn_environments - .first_mut() - .expect("primary environment"); - primary_environment.shell = Some(crate::shell::Shell { - shell_type: crate::shell::ShellType::Cmd, - shell_path: PathBuf::from("cmd"), - }); + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; - let environment_context = crate::context::EnvironmentContext::from_turn_context( - &turn_context, - session_shell.as_ref(), - ) - .render(); + let environment_update = user_input_texts(&update_items) + .into_iter() + .find(|text| text.contains("")) + .expect("environment update item should be emitted"); assert!( - environment_context.contains("cmd"), - "{environment_context}" + environment_update.contains(&format!("{}", cwd.display())), + "{environment_update}" ); + assert!(!environment_update.contains("")); } #[tokio::test] -async fn build_settings_update_items_emits_environment_item_for_time_changes() { +async fn record_context_updates_emits_environment_item_for_time_changes() { let (session, previous_context) = make_session_and_context().await; let previous_context = Arc::new(previous_context); let mut current_context = previous_context @@ -7453,10 +7547,8 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { current_context.current_date = Some("2026-02-27".to_string()); current_context.timezone = Some("Europe/Berlin".to_string()); - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; let environment_update = user_input_texts(&update_items) .into_iter() @@ -7467,7 +7559,7 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { } #[tokio::test] -async fn build_settings_update_items_omits_environment_item_when_disabled() { +async fn record_context_updates_omits_environment_item_when_disabled() { let (session, previous_context) = make_session_and_context().await; let previous_context = Arc::new(previous_context); let mut current_context = previous_context @@ -7479,12 +7571,16 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() { let mut config = (*current_context.config).clone(); config.include_environment_context = false; current_context.config = Arc::new(config); - current_context.current_date = Some("2026-02-27".to_string()); + let environment = current_context.environments.turn_environments[0].clone(); + current_context.environments.turn_environments[0] = TurnEnvironment::new( + environment.environment_id, + environment.environment, + PathUri::from_abs_path(&test_path_buf("/new-repo").abs()), + environment.shell, + ); - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; let user_texts = user_input_texts(&update_items); assert!( @@ -7495,6 +7591,23 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() { ); } +async fn record_context_update_items( + session: &Session, + previous_context: &TurnContext, + current_context: &TurnContext, +) -> Vec { + session + .record_context_updates_and_set_reference_context_item(previous_context) + .await; + let previous_len = session.clone_history().await.raw_items().len(); + + session + .record_context_updates_and_set_reference_context_item(current_context) + .await; + let history = session.clone_history().await; + history.raw_items()[previous_len..].to_vec() +} + #[tokio::test] async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() { let (session, previous_context) = make_session_and_context().await; @@ -7756,9 +7869,11 @@ async fn record_context_updates_includes_turn_context_fragments_on_steady_state_ }); let mut previous_context_item = turn_context.to_turn_context_item(); previous_context_item.turn_id = Some("previous-turn-id".to_string()); + let world_state = session.build_world_state(&turn_context).await; { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item)); + state.history.set_world_state_baseline(world_state); } session @@ -7873,15 +7988,14 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis } #[tokio::test] -async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_disabled() { +async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_empty() { let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { let _ = config.features.enable(Feature::MultiAgentV2); - config.multi_agent_v2.usage_hint_enabled = false; - config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string()); - config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string()); + config.multi_agent_v2.root_agent_usage_hint_text = None; + config.multi_agent_v2.subagent_usage_hint_text = None; }, ) .await; @@ -7910,7 +8024,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist status: "completed".to_string(), revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }], /*reference_context_item*/ None, ) @@ -8163,7 +8277,7 @@ async fn handle_output_item_done_records_image_save_history_message() { status: "completed".to_string(), revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { @@ -8195,7 +8309,7 @@ async fn handle_output_item_done_records_image_save_history_message() { ), ); let expected = vec![image_message, item]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); assert_eq!( std::fs::read(&expected_saved_path).expect("saved file"), b"foo" @@ -8220,7 +8334,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { status: "completed".to_string(), revised_prompt: Some("broken payload".to_string()), result: "_-8".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { @@ -8238,7 +8352,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { let history = session.clone_history().await; let expected = vec![item]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); assert!(!expected_saved_path.exists()); } @@ -8386,7 +8500,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn_context, std::slice::from_ref(&compacted_summary)) @@ -8429,9 +8543,11 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline .with_model(next_model.to_string(), &session.services.models_manager) .await; let previous_context_item = previous_context.to_turn_context_item(); + let world_state = session.build_world_state(&previous_context).await; { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item.clone())); + state.history.set_world_state_baseline(world_state); } let rollout_path = attach_thread_persistence(&mut session).await; @@ -8985,10 +9101,10 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() text: "late pending input".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!( - history.raw_items().iter().any(|item| item == &expected), + strip_metadata_from_items(history.raw_items()).contains(&expected), "expected pending input to be persisted into history on turn completion" ); @@ -9418,7 +9534,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { text: "late pending input".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_state = { let mut active = sess.active_turn.lock().await; @@ -9677,7 +9793,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { sess: Arc::clone(&sess), @@ -9805,7 +9921,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { call_id: "call-1".to_string(), name: "shell_command".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let call = ToolRouter::build_tool_call(item.clone()) @@ -9890,7 +10006,7 @@ async fn sample_rollout( text: "first user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user1), @@ -9905,7 +10021,7 @@ async fn sample_rollout( text: "assistant reply one".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant1), @@ -9920,12 +10036,14 @@ async fn sample_rollout( let user_messages1 = collect_user_messages(&snapshot1); let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1); live_history.replace(rebuilt1); - let (window_number, window_id) = session.advance_auto_compact_window().await; + let (window_number, window_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), replacement_history: None, window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), })); let user2 = ResponseItem::Message { @@ -9935,7 +10053,7 @@ async fn sample_rollout( text: "second user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user2), @@ -9950,7 +10068,7 @@ async fn sample_rollout( text: "assistant reply two".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant2), @@ -9965,12 +10083,14 @@ async fn sample_rollout( let user_messages2 = collect_user_messages(&snapshot2); let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2); live_history.replace(rebuilt2); - let (window_number, window_id) = session.advance_auto_compact_window().await; + let (window_number, window_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), replacement_history: None, window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), })); let user3 = ResponseItem::Message { @@ -9980,7 +10100,7 @@ async fn sample_rollout( text: "third user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user3), @@ -9995,7 +10115,7 @@ async fn sample_rollout( text: "assistant reply three".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant3), @@ -10141,7 +10261,7 @@ while :; do sleep 1; done"#, }) .to_string(), call_id: "shell-cleanup-call".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let call = ToolRouter::build_tool_call(item)? .expect("shell command response item should build a tool call"); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index b050cbb308..3292990b27 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -535,7 +535,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message text: "stale developer message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -544,7 +544,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], InitialContextInjection::BeforeLastUserMessage, diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index 4548ed3198..17f27c106b 100644 --- a/codex-rs/core/src/session/token_budget.rs +++ b/codex-rs/core/src/session/token_budget.rs @@ -3,42 +3,35 @@ use super::turn_context::TurnContext; use crate::context::ContextualUserFragment; use codex_features::Feature; -const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75]; - -pub(super) async fn maybe_record_token_budget_remaining_context( +pub(super) async fn maybe_record( sess: &Session, turn_context: &TurnContext, - tokens_before_sampling: i64, - tokens_after_sampling: i64, + tokens_until_compaction: i64, ) { if !turn_context.config.features.enabled(Feature::TokenBudget) { return; } - let Some(model_context_window) = turn_context.model_context_window() else { + + let Some(config) = turn_context.config.token_budget.as_ref().filter(|config| { + config + .reminder_threshold_tokens + .is_some_and(|threshold| tokens_until_compaction <= threshold) + }) else { return; }; - if model_context_window <= 0 || tokens_after_sampling <= tokens_before_sampling { + + let reminder_due = { + let mut state = sess.state.lock().await; + state.claim_token_budget_reminder() + }; + if !reminder_due { return; } - let tokens_before_sampling = tokens_before_sampling.max(0); - let tokens_after_sampling = tokens_after_sampling.max(0); - let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| { - tokens_before_sampling.saturating_mul(100) < model_context_window.saturating_mul(*threshold) - && tokens_after_sampling.saturating_mul(100) - >= model_context_window.saturating_mul(*threshold) - }); - if !crossed_threshold { - return; - } - - let tokens_left = model_context_window - .saturating_sub(tokens_after_sampling) - .max(0); - - let response_item = ContextualUserFragment::into( - crate::context::TokenBudgetRemainingContext::new(tokens_left), - ); + let response_item = ContextualUserFragment::into(crate::context::TokenBudgetReminder::new( + &config.reminder_message_template, + tokens_until_compaction, + )); sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 28c0f65612..84442a6caf 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -42,6 +42,7 @@ use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::PreviousTurnSettings; use crate::session::TurnInput; use crate::session::session::Session; +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::TurnItemContributorPolicy; @@ -100,6 +101,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::PlanDeltaEvent; use codex_protocol::protocol::ReasoningContentDeltaEvent; use codex_protocol::protocol::ReasoningRawContentDeltaEvent; +use codex_protocol::protocol::SafetyBufferingEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; @@ -240,6 +242,21 @@ pub(crate) async fn run_turn( ) .await?; + if turn_context + .config + .features + .enabled(Feature::DeferredExecutor) + { + let step_context = StepContext { + environments: sess.services.turn_environments.snapshot().await, + }; + sess.record_step_environment_context_if_changed( + turn_context.as_ref(), + &step_context, + ) + .await; + } + // Construct the input that we will send to the model. let sampling_request_input: Vec = async { sess.clone_history() @@ -254,8 +271,7 @@ pub(crate) async fn run_turn( window_id, CodexResponsesRequestKind::Turn, ); - let tokens_before_sampling = sess.get_total_token_usage().await; - let (sampling_request_output, sampling_request_input) = run_sampling_request( + run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_extension_data), @@ -265,17 +281,11 @@ pub(crate) async fn run_turn( sampling_request_input, cancellation_token.child_token(), ) - .await?; - - Ok(( - tokens_before_sampling, - sampling_request_output, - sampling_request_input, - )) + .await } .await; match sampling_request_result { - Ok((tokens_before_sampling, sampling_request_output, sampling_request_input)) => { + Ok((sampling_request_output, sampling_request_input)) => { let SamplingRequestResult { needs_follow_up: model_needs_follow_up, last_agent_message: sampling_request_last_agent_message, @@ -313,11 +323,20 @@ pub(crate) async fn run_turn( ); let tokens_after_sampling = token_status.active_context_tokens; - super::token_budget::maybe_record_token_budget_remaining_context( + let full_context_remaining = token_status + .full_context_window_limit + .map_or(i64::MAX, |limit| { + limit.saturating_sub(tokens_after_sampling) + }); + let tokens_until_compaction = token_status + .auto_compact_scope_limit + .saturating_sub(token_status.auto_compact_scope_tokens) + .min(full_context_remaining) + .max(0); + super::token_budget::maybe_record( sess.as_ref(), turn_context.as_ref(), - tokens_before_sampling, - tokens_after_sampling, + tokens_until_compaction, ) .await; @@ -331,7 +350,13 @@ pub(crate) async fn run_turn( } // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. - if token_limit_reached && needs_follow_up { + if turn_context + .config + .features + .enabled(Feature::AutoCompaction) + && token_limit_reached + && needs_follow_up + { if let Err(err) = run_auto_compact( &sess, &turn_context, @@ -631,13 +656,16 @@ async fn build_skills_and_plugins( sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); - for plugin in mentioned_plugins - .iter() - .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) - { - sess.services - .analytics_events_client - .track_plugin_used(tracking.clone(), plugin); + for summary in &mentioned_plugins { + if let Some(plugin) = sess + .services + .plugins_manager + .telemetry_metadata_for_capability_summary(summary) + { + sess.services + .analytics_events_client + .track_plugin_used(tracking.clone(), plugin); + } } let mut injection_items: Vec = match injected_host_skill_prompts { @@ -842,6 +870,14 @@ async fn run_pre_sampling_compact( turn_context: &Arc, client_session: &mut ModelClientSession, ) -> CodexResult<()> { + if !turn_context + .config + .features + .enabled(Feature::AutoCompaction) + { + return Ok(()); + } + maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; // Compact if the configured auto-compaction budget or usable context window is exhausted. @@ -1518,6 +1554,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option< | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::TurnModerationMetadata(_) + | EventMsg::SafetyBuffering(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -2054,7 +2091,8 @@ async fn try_run_sampling_request( } ResponseItem::Reasoning { .. } => true, ResponseItem::AgentMessage { .. } => false, - ResponseItem::LocalShellCall { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } @@ -2190,6 +2228,19 @@ async fn try_run_sampling_request( sess.emit_turn_moderation_metadata(&turn_context, metadata) .await; } + ResponseEvent::SafetyBuffering(buffering) => { + sess.send_event( + &turn_context, + EventMsg::SafetyBuffering(SafetyBufferingEvent { + model: turn_context.model_info.slug.clone(), + use_cases: buffering.use_cases, + reasons: buffering.reasons, + show_buffering_ui: buffering.show_buffering_ui, + faster_model: buffering.faster_model, + }), + ) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index bb434fa2c9..b2c50cf96a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -125,7 +125,7 @@ pub struct TurnContext { pub(crate) developer_instructions: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, - pub(crate) multi_agent_mode: Option, + pub(crate) multi_agent_mode: MultiAgentMode, pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, @@ -386,10 +386,8 @@ impl TurnContext { multi_agent_version: Some(self.multi_agent_version), multi_agent_mode: super::multi_agents::effective_multi_agent_mode( self.multi_agent_version, - &self.config.multi_agent_v2, &self.session_source, self.multi_agent_mode, - self.config.features.enabled(Feature::MultiAgentMode), ), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort.clone(), @@ -538,6 +536,7 @@ impl Session { session_configuration.forked_from_thread_id, session_configuration.parent_thread_id, &session_configuration.session_source, + session_configuration.thread_source.clone(), sub_id.clone(), cwd.clone(), &session_configuration.permission_profile(), @@ -798,7 +797,7 @@ impl Session { turn_context } - pub(crate) async fn maybe_emit_unknown_model_warning_for_turn(&self, tc: &TurnContext) { + pub(crate) async fn maybe_emit_model_warnings_for_turn(&self, tc: &TurnContext) { if tc.model_info.used_fallback_model_metadata { self.send_event( tc, @@ -811,6 +810,13 @@ impl Session { ) .await; } + + if let Some(message) = + unsupported_code_mode_warning(&tc.model_info, tc.config.features.get()) + { + self.send_event(tc, EventMsg::Warning(WarningEvent { message })) + .await; + } } pub(crate) async fn new_default_turn(&self) -> Arc { diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs index 7c9f003cb4..051799cf85 100644 --- a/codex-rs/core/src/session/turn_tests.rs +++ b/codex-rs/core/src/session/turn_tests.rs @@ -33,7 +33,7 @@ fn assistant_output_text(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs new file mode 100644 index 0000000000..5d12857748 --- /dev/null +++ b/codex-rs/core/src/session/world_state.rs @@ -0,0 +1,41 @@ +use super::turn_context::TurnContext; +use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::WorldState; +use crate::environment_selection::TurnEnvironmentSnapshot; +use codex_protocol::protocol::TurnContextItem; + +pub(super) fn build_world_state_from_turn_context( + turn_context: &TurnContext, + environment_subagents: &str, +) -> WorldState { + let mut world_state = WorldState::default(); + if turn_context.config.include_environment_context { + world_state.add_section( + EnvironmentsState::from_turn_context(turn_context) + .with_subagents(environment_subagents.to_string()), + ); + } + world_state +} + +pub(super) fn build_world_state_from_environment_snapshot( + turn_context: &TurnContext, + environments: &TurnEnvironmentSnapshot, +) -> WorldState { + let mut world_state = WorldState::default(); + if turn_context.config.include_environment_context { + world_state.add_section(EnvironmentsState::from_turn_context_with_environments( + turn_context, + environments, + )); + } + world_state +} + +pub(super) fn build_world_state_from_turn_context_item( + turn_context_item: &TurnContextItem, +) -> WorldState { + let mut world_state = WorldState::default(); + world_state.add_section(EnvironmentsState::from_turn_context_item(turn_context_item)); + world_state +} diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 0474eb8704..270134981b 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -10,6 +10,7 @@ use tracing::instrument; use tracing::warn; use crate::client::ModelClientSession; +use crate::guardian::routes_approval_to_guardian; use crate::responses_metadata::CodexResponsesRequestKind; use crate::session::INITIAL_SUBMIT_ID; use crate::session::session::Session; @@ -242,6 +243,19 @@ async fn schedule_startup_prewarm_inner( prewarm_started_at.elapsed(), /*status*/ None, ); + if routes_approval_to_guardian(&startup_turn_context) { + let guardian_session = Arc::clone(&session); + let guardian_parent_turn = Arc::clone(&startup_turn_context); + drop(tokio::spawn(async move { + if let Err(err) = guardian_session + .guardian_review_session + .initialize(Arc::clone(&guardian_session), guardian_parent_turn) + .await + { + warn!("failed to initialize guardian review session: {err:#}"); + } + })); + } let startup_cancellation_token = CancellationToken::new(); let built_tools_started_at = Instant::now(); let startup_router = built_tools( @@ -295,6 +309,5 @@ async fn schedule_startup_prewarm_inner( websocket_warmup_started_at.elapsed(), /*status*/ None, ); - Ok(client_session) } diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs index e1fde27504..419e85be04 100644 --- a/codex-rs/core/src/state/auto_compact_window.rs +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -1,6 +1,13 @@ use codex_protocol::protocol::TokenUsage; use uuid::Uuid; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AutoCompactWindowIds { + pub(crate) first_window_id: Uuid, + pub(crate) previous_window_id: Option, + pub(crate) window_id: Uuid, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AutoCompactWindowSnapshot { pub(crate) prefill_input_tokens: Option, @@ -15,7 +22,7 @@ enum AutoCompactWindowPrefill { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct AutoCompactWindow { window_number: u64, - window_id: Uuid, + ids: AutoCompactWindowIds, new_context_window_requested: bool, /// Absolute input-token baseline for the current compaction window. /// @@ -23,15 +30,22 @@ pub(super) struct AutoCompactWindow { /// not the growth itself; server-observed usage replaces estimated /// resume/recompute baselines when available. prefill_input_tokens: Option, + token_budget_reminder_delivered: bool, } impl AutoCompactWindow { pub(super) fn new() -> Self { + let window_id = Uuid::now_v7(); Self { window_number: 0, - window_id: Uuid::now_v7(), + ids: AutoCompactWindowIds { + first_window_id: window_id, + previous_window_id: None, + window_id, + }, new_context_window_requested: false, prefill_input_tokens: None, + token_budget_reminder_delivered: false, } } @@ -43,20 +57,26 @@ impl AutoCompactWindow { self.window_number } - pub(super) fn window_id(&self) -> Uuid { - self.window_id + pub(super) fn ids(&self) -> AutoCompactWindowIds { + self.ids } - pub(super) fn restore(&mut self, window_number: u64, window_id: Uuid) { + pub(super) fn restore(&mut self, window_number: u64, ids: AutoCompactWindowIds) { self.window_number = window_number; - self.window_id = window_id; + self.ids = ids; } - pub(super) fn advance(&mut self) -> (u64, Uuid) { + pub(super) fn advance(&mut self) -> (u64, AutoCompactWindowIds) { self.window_number = self.window_number.saturating_add(1); - self.window_id = Uuid::now_v7(); + self.ids.previous_window_id = Some(self.ids.window_id); + self.ids.window_id = Uuid::now_v7(); self.new_context_window_requested = false; - (self.window_number, self.window_id) + self.token_budget_reminder_delivered = false; + (self.window_number, self.ids) + } + + pub(super) fn claim_token_budget_reminder(&mut self) -> bool { + !std::mem::replace(&mut self.token_budget_reminder_delivered, true) } pub(super) fn request_new_context_window(&mut self) { @@ -118,22 +138,45 @@ mod tests { let mut window = AutoCompactWindow::new(); assert_eq!(window.window_number(), 0); - assert_eq!(window.window_id().get_version_num(), 7); + let initial_window_id = window.ids().window_id; + assert_eq!(initial_window_id.get_version_num(), 7); + assert_eq!( + window.ids(), + AutoCompactWindowIds { + first_window_id: initial_window_id, + previous_window_id: None, + window_id: initial_window_id, + } + ); + let first_window_id = initial_window_id; let restored_window_id = Uuid::now_v7(); - window.restore(/*window_number*/ 3, restored_window_id); + let restored_previous_window_id = Uuid::now_v7(); + window.restore( + /*window_number*/ 3, + AutoCompactWindowIds { + first_window_id, + previous_window_id: Some(restored_previous_window_id), + window_id: restored_window_id, + }, + ); assert_eq!(window.window_number(), 3); - assert_eq!(window.window_id(), restored_window_id); + assert_eq!(window.ids().window_id, restored_window_id); + assert!(window.claim_token_budget_reminder()); + assert!(!window.claim_token_budget_reminder()); window.request_new_context_window(); assert!(window.take_new_context_window_request()); assert!(!window.take_new_context_window_request()); window.request_new_context_window(); - let (window_number, window_id) = window.advance(); + let (window_number, ids) = window.advance(); assert_eq!(window_number, 4); assert_eq!(window.window_number(), 4); - assert_eq!(window.window_id(), window_id); - assert_eq!(window_id.get_version_num(), 7); - assert_ne!(window_id, restored_window_id); + assert_eq!(window.ids(), ids); + assert_eq!(ids.first_window_id, first_window_id); + assert_eq!(ids.previous_window_id, Some(restored_window_id)); + assert_eq!(ids.window_id.get_version_num(), 7); + assert_ne!(ids.window_id, restored_window_id); assert!(!window.take_new_context_window_request()); + assert!(window.claim_token_budget_reminder()); assert_eq!( window.snapshot(), diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index c693221bed..20ff78b54f 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -5,6 +5,7 @@ mod session; mod turn; pub(crate) use additional_context::AdditionalContextStore; +pub(crate) use auto_compact_window::AutoCompactWindowIds; pub(crate) use auto_compact_window::AutoCompactWindowSnapshot; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 83cef227f8..6444dc8cf3 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -6,10 +6,10 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; -use uuid::Uuid; use super::AdditionalContextStore; use super::auto_compact_window::AutoCompactWindow; +use super::auto_compact_window::AutoCompactWindowIds; use super::auto_compact_window::AutoCompactWindowSnapshot; use crate::context_manager::ContextManager; use crate::session::PreviousTurnSettings; @@ -148,19 +148,27 @@ impl SessionState { self.auto_compact_window.snapshot() } + pub(crate) fn claim_token_budget_reminder(&mut self) -> bool { + self.auto_compact_window.claim_token_budget_reminder() + } + pub(crate) fn auto_compact_window_number(&self) -> u64 { self.auto_compact_window.window_number() } - pub(crate) fn auto_compact_window_id(&self) -> Uuid { - self.auto_compact_window.window_id() + pub(crate) fn auto_compact_window_ids(&self) -> AutoCompactWindowIds { + self.auto_compact_window.ids() } - pub(crate) fn restore_auto_compact_window(&mut self, window_number: u64, window_id: Uuid) { - self.auto_compact_window.restore(window_number, window_id); + pub(crate) fn restore_auto_compact_window( + &mut self, + window_number: u64, + ids: AutoCompactWindowIds, + ) { + self.auto_compact_window.restore(window_number, ids); } - pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, Uuid) { + pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, AutoCompactWindowIds) { self.auto_compact_window.advance() } @@ -168,7 +176,9 @@ impl SessionState { self.auto_compact_window.request_new_context_window(); } - pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option<(u64, Uuid)> { + pub(crate) fn start_new_context_window_if_requested( + &mut self, + ) -> Option<(u64, AutoCompactWindowIds)> { if !self.auto_compact_window.take_new_context_window_request() { return None; } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 5a2f854782..71a9ca7d21 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -627,7 +627,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti id: None, call_id: call_id.clone(), output: output.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::CustomToolCallOutput { @@ -639,7 +639,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti call_id: call_id.clone(), name: name.clone(), output: output.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.as_function_call_output_payload(); @@ -647,7 +647,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti id: None, call_id: call_id.clone(), output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::ToolSearchOutput { @@ -661,7 +661,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti status: status.clone(), execution: execution.clone(), tools: tools.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), _ => None, } diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 45c5d18e68..b01ed4c4ca 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -42,7 +42,7 @@ fn assistant_output_text_with_phase(text: &str, phase: Option) -> text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -53,7 +53,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { id: None, status: Some("completed".to_string()), action: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchCall { id: None, @@ -61,7 +61,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { status: None, execution: "client".to_string(), arguments: serde_json::json!({"query": "calendar"}), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchOutput { id: None, @@ -69,7 +69,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -94,7 +94,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -102,13 +102,13 @@ fn external_context_pollution_items_exclude_local_tool_calls() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -116,14 +116,14 @@ fn external_context_pollution_items_exclude_local_tool_calls() { call_id: "custom-1".to_string(), name: "apply_patch".to_string(), input: "*** Begin Patch\n*** End Patch\n".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "custom-1".to_string(), name: Some("apply_patch".to_string()), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_output_text("plain assistant text"), ]; @@ -425,7 +425,7 @@ fn completed_item_defers_mailbox_delivery_for_image_generation_calls() { status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!(completed_item_defers_mailbox_delivery_to_next_turn( diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b24d43df3d..cb532445fd 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -113,7 +113,7 @@ pub(crate) fn interrupted_turn_history_marker( text: marker.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } } @@ -483,7 +483,7 @@ impl Session { } let turn_context = self.new_default_turn_with_sub_id(sub_id).await; - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + self.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) .await; self.start_task(turn_context, Vec::new(), RegularTask::new()) .await; diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 7c18a5f787..19ac0fdc35 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -248,7 +248,7 @@ pub(crate) async fn exit_review_mode( role: "user".to_string(), content: vec![ContentItem::InputText { text: user_message }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -269,7 +269,7 @@ pub(crate) async fn exit_review_mode( text: assistant_message, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ) .await; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index c5b44a6494..844e327bdf 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -224,6 +224,8 @@ pub(crate) async fn execute_user_shell_command( network_sandbox_policy: permission_profile.network_sandbox_policy(), windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let stdout_stream = Some(StdoutStream { diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 5d64b12b0b..17788ba51f 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,7 +1010,7 @@ impl ThreadManager { }; let initial_multi_agent_mode = match source_thread_id { Some(thread_id) => match self.get_thread(thread_id).await { - Ok(thread) => thread.config_snapshot().await.multi_agent_mode, + Ok(thread) => Some(thread.config_snapshot().await.multi_agent_mode), Err(_) => history.get_latest_effective_multi_agent_mode(), }, None => history.get_latest_effective_multi_agent_mode(), @@ -1070,7 +1070,7 @@ impl ThreadManager { } fn agent_control_for_config(&self, config: &Config) -> AgentControl { - AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget) + AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget.clone()) } #[cfg(test)] @@ -1654,7 +1654,7 @@ fn stored_thread_to_initial_history( })?; Ok(InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: history.items, + history: Arc::new(history.items), rollout_path: rollout_path.or(stored_thread.rollout_path), })) } @@ -1691,7 +1691,7 @@ fn truncate_before_nth_user_message( n: usize, snapshot_state: &SnapshotTurnState, ) -> InitialHistory { - let items: Vec = history.get_rollout_items(); + let items = history.get_rollout_items().to_vec(); let user_positions = truncation::user_message_positions_in_rollout(&items); let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() { if let Some(cut_idx) = snapshot_state @@ -1723,7 +1723,7 @@ struct SnapshotTurnState { fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { let rollout_items = history.get_rollout_items(); let mut builder = ThreadHistoryBuilder::new(); - for item in &rollout_items { + for item in rollout_items { builder.handle_rollout_item(item); } let active_turn_id = builder.active_turn_id_if_explicit(); @@ -1747,7 +1747,7 @@ fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { }; } - let Some(last_user_position) = truncation::user_message_positions_in_rollout(&rollout_items) + let Some(last_user_position) = truncation::user_message_positions_in_rollout(rollout_items) .last() .copied() else { @@ -1788,7 +1788,9 @@ fn fork_history_from_snapshot( InitialHistory::New => InitialHistory::New, InitialHistory::Cleared => InitialHistory::Cleared, InitialHistory::Forked(history) => InitialHistory::Forked(history), - InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history), + InitialHistory::Resumed(resumed) => { + InitialHistory::Forked(Arc::unwrap_or_clone(resumed.history)) + } }; if snapshot_state.ends_mid_turn { append_interrupted_boundary( @@ -1834,12 +1836,13 @@ fn append_interrupted_boundary( history.push(aborted_event); InitialHistory::Forked(history) } - InitialHistory::Resumed(mut resumed) => { + InitialHistory::Resumed(resumed) => { + let mut history = Arc::unwrap_or_clone(resumed.history); if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) { - resumed.history.push(RolloutItem::ResponseItem(marker)); + history.push(RolloutItem::ResponseItem(marker)); } - resumed.history.push(aborted_event); - InitialHistory::Forked(resumed.history) + history.push(aborted_event); + InitialHistory::Forked(history) } } } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d4515e87d8..6375bd914c 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -42,7 +42,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn assistant_msg(text: &str) -> ResponseItem { @@ -53,7 +53,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -82,7 +82,7 @@ fn truncates_before_requested_user_message() { }], content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -90,7 +90,7 @@ fn truncates_before_requested_user_message() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_msg("a4"), ]; @@ -116,7 +116,7 @@ fn truncates_before_requested_user_message() { RolloutItem::ResponseItem(items[2].clone()), ]; assert_eq!( - serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(got_items).unwrap(), serde_json::to_value(&expected_items).unwrap() ); @@ -256,7 +256,7 @@ async fn ignores_session_prefix_messages_when_truncating() { ]; assert_eq!( - serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(got_items).unwrap(), serde_json::to_value(&expected).unwrap() ); } @@ -963,7 +963,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { config.clone(), InitialHistory::Resumed(ResumedHistory { conversation_id: source.thread_id, - history: vec![RolloutItem::ResponseItem(user_msg("hello"))], + history: Arc::new(vec![RolloutItem::ResponseItem(user_msg("hello"))]), rollout_path: Some(rollout_path.clone()), }), auth_manager.clone(), @@ -1315,7 +1315,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor assert!(!snapshot_turn_state(&history).ends_mid_turn); let rollout_items: Vec<_> = history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem( @@ -1435,7 +1435,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { .expect("read forked rollout history"); let rollout_items: Vec<_> = history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); @@ -1522,7 +1522,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ let forked_rollout_items: Vec<_> = history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem( @@ -1560,7 +1560,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ .expect("read re-forked rollout history"); let reforked_rollout_items: Vec<_> = reforked_history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index bdbdbc4c75..2df3d1c98e 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -15,7 +15,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -27,7 +27,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -39,7 +39,7 @@ fn developer_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -79,7 +79,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { }], content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -87,7 +87,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/tools/code_mode/delegate.rs b/codex-rs/core/src/tools/code_mode/delegate.rs index 7dd6615095..c467fe0d6f 100644 --- a/codex-rs/core/src/tools/code_mode/delegate.rs +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -303,7 +303,7 @@ impl CoreTurnHost { call_id, name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(text), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]) .await .map_err(|_| { diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 34c41d835d..757125a03d 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -645,7 +645,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = codex_apply_patch::apply_patch( @@ -729,7 +729,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd"); for patch in [ "*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch", @@ -782,7 +782,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = codex_apply_patch::apply_patch( diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index ae79ba405d..025cde231c 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -329,16 +329,18 @@ mod tests { text: "extension history".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let mut expected_history_item = history_item.clone(); + expected_history_item.set_turn_id_if_missing(&turn_id); let raw_history_event = rx.recv().await.expect("history raw response item event"); let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else { panic!("expected raw response item event"); }; - assert_eq!(raw_history_item.item, history_item); + assert_eq!(raw_history_item.item, expected_history_item); let invocation = ToolInvocation { session, turn, @@ -377,7 +379,7 @@ mod tests { ); assert_eq!( captured_call.conversation_history.items(), - std::slice::from_ref(&history_item) + std::slice::from_ref(&expected_history_item) ); match captured_call.payload { ToolPayload::Function { arguments } => { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 70f71f3114..08f8ca071f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -24,7 +24,6 @@ pub struct SpawnAgentToolOptions { pub available_models: Vec, pub agent_type_description: String, pub hide_agent_type_model_reasoning: bool, - pub include_usage_hint: bool, pub usage_hint_text: Option, } @@ -66,7 +65,6 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { available_models_description.as_deref(), inherited_model_guidance, return_value_description, - options.include_usage_hint, options.usage_hint_text, ), strict: false, @@ -99,7 +97,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { description: spawn_agent_tool_description_v2( available_models_description.as_deref(), inherited_model_guidance, - options.include_usage_hint, options.usage_hint_text, ), strict: false, @@ -648,7 +645,6 @@ fn spawn_agent_tool_description( available_models_description: Option<&str>, inherited_model_guidance: Option<&str>, return_value_description: &str, - include_usage_hint: bool, usage_hint_text: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); @@ -660,9 +656,6 @@ fn spawn_agent_tool_description( Spawn a sub-agent for a well-scoped task. {return_value_description} {inherited_model_guidance}"# ); - if !include_usage_hint { - return tool_description; - } if let Some(usage_hint_text) = usage_hint_text { return format!( r#" @@ -711,7 +704,6 @@ Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegati fn spawn_agent_tool_description_v2( available_models_description: Option<&str>, inherited_model_guidance: Option<&str>, - include_usage_hint: bool, usage_hint_text: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); @@ -731,9 +723,6 @@ The new agent's canonical task name will be provided to it along with the messag Note that passing `fork_turns="none"` will not pass any surrounding context to the spawned subagent, which may cause the agent to lack the context it needs to complete its task, whereas `fork_turns="all"` will provide the subagent with all surrounding context."# ); - if !include_usage_hint { - return tool_description; - } if let Some(usage_hint_text) = usage_hint_text { return format!( r#" diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 1ef93b77f0..0228f7acb9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -45,7 +45,6 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -121,7 +120,6 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { available_models: Vec::new(), agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -178,7 +176,6 @@ fn spawn_agent_tool_caps_visible_model_summaries() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -222,7 +219,6 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { available_models: vec![model_preset("visible", /*show_in_picker*/ true)], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: true, - include_usage_hint: true, usage_hint_text: None, }); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index eecdfc178d..2561cd7ded 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1148,11 +1148,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - turn.multi_agent_mode = Some(MultiAgentMode::Proactive); + turn.multi_agent_mode = MultiAgentMode::Proactive; set_turn_config(&mut turn, config); let session = Arc::new(session); @@ -1191,10 +1187,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat child_snapshot.session_source.get_agent_path().as_deref(), Some("/root/test_process") ); - assert_eq!( - child_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(child_snapshot.multi_agent_mode, MultiAgentMode::Proactive); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id && matches!( @@ -1205,11 +1198,6 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-spawn-message") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && communication.trigger_turn ) })); @@ -1237,11 +1225,6 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-send-message") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && !communication.trigger_turn ) })); @@ -2055,11 +2038,6 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() if communication.author == AgentPath::root() && communication.recipient == worker_path && communication.encrypted_content.as_deref() == Some("continue") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && communication.trigger_turn ) })); @@ -2838,7 +2816,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { text: "materialized".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })]), AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 81eebed4a3..7fdb80e8bc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -6,7 +6,6 @@ use super::*; use crate::tools::context::FunctionToolOutput; use crate::turn_timing::now_unix_timestamp_ms; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] @@ -100,12 +99,8 @@ pub(crate) async fn handle_message_string_tool( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let mut communication = + let communication = communication_from_tool_message(author, receiver_agent_path.clone(), message); - communication - .metadata - .get_or_insert_with(ResponseItemMetadata::default) - .source_call_id = Some(call_id.clone()); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index ed63102d6a..837257dec9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -8,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -52,12 +51,8 @@ async fn handle_spawn_agent( let fork_mode = args.fork_mode()?; let multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( turn.multi_agent_version, - &turn.config.multi_agent_v2, &turn.session_source, turn.multi_agent_mode, - turn.config - .features - .enabled(codex_features::Feature::MultiAgentMode), ); let role_name = args .agent_type @@ -127,12 +122,8 @@ async fn handle_spawn_agent( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let mut communication = + let communication = communication_from_tool_message(author, new_agent_path.clone(), message); - communication - .metadata - .get_or_insert_with(ResponseItemMetadata::default) - .source_call_id = Some(call_id.clone()); Op::InterAgentCommunication { communication } } initial_operation => initial_operation, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 84911fb815..09a6844f19 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -1,4 +1,3 @@ -use codex_features::Feature; use codex_protocol::items::ImageViewItem; use codex_protocol::items::TurnItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -8,9 +7,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; -use codex_utils_image::PromptImageMode; use codex_utils_image::data_url_from_bytes; -use codex_utils_image::load_for_prompt_bytes; use serde::Deserialize; use crate::function_tool::FunctionCallError; @@ -195,24 +192,8 @@ impl ViewImageHandler { DEFAULT_IMAGE_DETAIL }; - let image_url = if turn.config.features.enabled(Feature::ResizeAllImages) { - // The history insertion path owns image decoding and resizing when this is enabled. - data_url_from_bytes("application/octet-stream", &file_bytes) - } else { - let image_mode = if use_original_detail { - PromptImageMode::Original - } else { - PromptImageMode::ResizeToFit - }; - load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode) - .map_err(|error| { - FunctionCallError::RespondToModel(format!( - "unable to process image at `{}`: {error}", - abs_path.display() - )) - })? - .into_data_url() - }; + // The history insertion path owns image decoding and resizing. + let image_url = data_url_from_bytes("application/octet-stream", &file_bytes); let item = TurnItem::ImageView(ImageViewItem { id: call_id, @@ -420,9 +401,6 @@ mod tests { }) .await; - let Err(FunctionCallError::RespondToModel(message)) = result else { - panic!("expected image processing error"); - }; - assert!(message.contains("unable to process image"), "{message}"); + result.expect("explicit high detail should be accepted"); } } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 803b8d6a9f..aaa0dfe9d1 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -84,7 +84,9 @@ impl ToolOrchestrator { }; let attempt_with_network_approval = SandboxAttempt { sandbox: attempt.sandbox, + sandbox_requested: attempt.sandbox_requested, permissions: attempt.permissions, + exec_server_permissions: attempt.exec_server_permissions, enforce_managed_network: attempt.enforce_managed_network, manager: attempt.manager, sandbox_cwd: attempt.sandbox_cwd, @@ -225,16 +227,27 @@ impl ToolOrchestrator { &file_system_sandbox_policy, ); let managed_network_active = turn_ctx.network.is_some(); - let initial_sandbox = match sandbox_override { - SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None, - SandboxOverride::NoOverride => self.sandbox.select_initial( + let sandbox_preference = tool.sandbox_preference(); + let sandbox_requested = match sandbox_override { + SandboxOverride::BypassSandboxFirstAttempt => false, + SandboxOverride::NoOverride => self.sandbox.should_sandbox( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), - turn_ctx.windows_sandbox_level, + sandbox_preference, managed_network_active, ), }; + let initial_sandbox = if sandbox_requested { + self.sandbox.select_initial( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + turn_ctx.windows_sandbox_level, + managed_network_active, + ) + } else { + SandboxType::None + }; // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); @@ -246,7 +259,9 @@ impl ToolOrchestrator { let workspace_roots = turn_ctx.config.effective_workspace_roots(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, + sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, @@ -401,16 +416,23 @@ impl ToolOrchestrator { .await?; } - let retry_sandbox = if unsandboxed_allowed { - SandboxType::None - } else { + let retry_sandbox_requested = !unsandboxed_allowed + && self.sandbox.should_sandbox( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + managed_network_active, + ); + let retry_sandbox = if retry_sandbox_requested { self.sandbox.select_initial( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), + sandbox_preference, turn_ctx.windows_sandbox_level, managed_network_active, ) + } else { + SandboxType::None }; let retry_codex_linux_sandbox_exe = if unsandboxed_allowed { None @@ -419,7 +441,9 @@ impl ToolOrchestrator { }; let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, + sandbox_requested: retry_sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index a38ab8af26..77ddfe48d5 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -157,7 +157,7 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() namespace: Some("mcp__codex_apps__calendar".to_string()), arguments: "{}".to_string(), call_id: "call-namespace".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call"); @@ -340,11 +340,13 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow text: "extension history".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let mut expected_history_item = history_item.clone(); + expected_history_item.set_turn_id_if_missing(&turn.sub_id); let router = ToolRouter::from_turn_context( &turn, @@ -376,7 +378,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow namespace: Some("extension/".to_string()), arguments: json!({ "message": "hello" }).to_string(), call_id: "call-extension".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call"); let result = router @@ -404,7 +406,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow json!({ "arguments": { "message": "hello" }, "callId": "call-extension", - "conversationHistory": [history_item], + "conversationHistory": [expected_history_item], "ok": true, }) ); diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 054f2c7c15..f1c6f43aa3 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -220,7 +220,9 @@ async fn file_system_sandbox_context_uses_active_attempt() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::MacosSeatbelt, + sandbox_requested: true, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, @@ -286,7 +288,9 @@ async fn no_sandbox_attempt_has_no_file_system_context() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 91db26aaae..9b485dbe58 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -106,7 +106,9 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: let manager = SandboxManager::new(); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index c08148c7ee..a77645f941 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -166,6 +166,8 @@ pub(super) async fn try_run_zsh_fork( network_sandbox_policy, windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; let effective_timeout = Duration::from_millis( @@ -898,6 +900,8 @@ impl CoreShellCommandExecutor { network_sandbox_policy: self.network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: self.arg0.clone(), + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }, /*stdout_stream*/ None, after_spawn, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 847a3c7584..77f68d96b5 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -11,6 +11,7 @@ use crate::session::turn_context::TurnContext; use crate::state::SessionServices; use crate::tools::hook_names::HookToolName; use crate::tools::network_approval::NetworkApprovalSpec; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; @@ -24,6 +25,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -408,7 +410,11 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { pub(crate) struct SandboxAttempt<'a> { pub sandbox: SandboxType, + /// Whether policy requested sandboxing, independent of this host's concrete wrapper. + pub sandbox_requested: bool, pub permissions: &'a codex_protocol::models::PermissionProfile, + /// Canonical permissions before this host materializes workspace roots. + pub exec_server_permissions: &'a codex_protocol::models::PermissionProfile, pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a PathUri, @@ -460,6 +466,10 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let exec_server_permissions = effective_permission_profile( + self.exec_server_permissions, + command.additional_permissions.as_ref(), + ); let request = self .manager .transform(SandboxTransformRequest { @@ -477,11 +487,23 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; - Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( + let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( request, options, self.workspace_roots.to_vec(), - )) + ); + if self.sandbox_requested { + exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { + permissions: exec_server_permissions.into(), + cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), + workspace_roots: Vec::new(), + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + use_legacy_landlock: self.use_legacy_landlock, + }); + exec_request.exec_server_enforce_managed_network = self.enforce_managed_network; + } + Ok(exec_request) } } diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index f4d6e6ce42..c647e1c401 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -4,7 +4,6 @@ use crate::tools::hook_names::HookToolName; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; @@ -202,21 +201,23 @@ fn deny_read_blocks_explicit_escalation_and_policy_bypass() { } #[test] -fn exec_server_env_keeps_command_native() { +fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let cwd: AbsolutePathBuf = std::env::current_dir() .expect("current dir") .try_into() .expect("absolute cwd"); let cwd_uri = PathUri::from_abs_path(&cwd); - let permissions = codex_protocol::models::PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::default(), - NetworkSandboxPolicy::Restricted, - ); + let exec_server_permissions = codex_protocol::models::PermissionProfile::workspace_write(); + let permissions = exec_server_permissions + .clone() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)); let manager = SandboxManager::new(); let attempt = SandboxAttempt { - sandbox: SandboxType::MacosSeatbelt, + sandbox: SandboxType::None, + sandbox_requested: true, permissions: &permissions, - enforce_managed_network: false, + exec_server_permissions: &exec_server_permissions, + enforce_managed_network: true, manager: &manager, sandbox_cwd: &cwd_uri, workspace_roots: std::slice::from_ref(&cwd), @@ -252,4 +253,16 @@ fn exec_server_env_keeps_command_native() { ); assert_eq!(request.arg0, None); assert_eq!(request.sandbox, SandboxType::None); + assert_eq!( + request.exec_server_sandbox, + Some(codex_exec_server::FileSystemSandboxContext { + permissions: exec_server_permissions.into(), + cwd: Some(cwd_uri), + workspace_roots: Vec::new(), + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }) + ); + assert!(request.exec_server_enforce_managed_network); } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index af7acf17a0..51cd436503 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -326,7 +326,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { } pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool { - turn_context.model_info.supports_search_tool + turn_context.model_info.supports_search_tool && namespace_tools_enabled(turn_context) } pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool { @@ -708,7 +708,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } if features.enabled(Feature::TokenBudget) { - planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); + if features.enabled(Feature::AutoCompaction) { + planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); + } planned_tools.add(GetContextRemainingHandler); } @@ -786,7 +788,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu .config .multi_agent_v2 .hide_spawn_agent_metadata, - include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), }), tool_namespace, @@ -819,18 +820,16 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu } else { let agent_type_description = agent_type_description(turn_context, context.default_agent_type_description); - let exposure = - if search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context) { - ToolExposure::Deferred - } else { - ToolExposure::Direct - }; + let exposure = if search_tool_enabled(turn_context) { + ToolExposure::Deferred + } else { + ToolExposure::Direct + }; planned_tools.add_with_exposure( SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: turn_context.available_models.clone(), agent_type_description, hide_agent_type_model_reasoning: false, - include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), }), exposure, @@ -943,7 +942,7 @@ fn append_tool_search_executor( planned_tools: &mut PlannedTools, ) { let turn_context = context.turn_context; - if !(search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context)) { + if !search_tool_enabled(turn_context) { return; } @@ -990,7 +989,6 @@ fn append_extension_tool_executors( reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } if search_tool_enabled(turn_context) - && namespace_tools_enabled(turn_context) && planned_tools .runtimes() .iter() diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 5d70fb8f17..3fe33b141b 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -39,6 +39,8 @@ use crate::tools::router::ToolRouterParams; use crate::tools::router::ToolSuggestCandidates; use crate::tools::router::ToolSuggestPresentation; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; + #[derive(Default)] struct ToolPlanInputs { mcp_tools: Option>, @@ -1151,19 +1153,48 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { }); }) .await; - v2.assert_visible_contains(&[ + v2.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); + v2.assert_visible_lacks(&[ "spawn_agent", "send_message", "followup_task", "wait_agent", "interrupt_agent", "list_agents", + "send_input", + "resume_agent", + "assign_task", + "close_agent", ]); - v2.assert_visible_lacks(&["send_input", "resume_agent", "assign_task", "close_agent"]); - let spawn_agent_description = match v2.visible_spec("spawn_agent") { - ToolSpec::Function(tool) => tool.description.as_str(), - other => panic!("expected spawn_agent function spec, got {other:?}"), + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "interrupt_agent", + "list_agents", + ] { + assert!( + v2.namespace_function_names(MULTI_AGENT_V2_NAMESPACE) + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace" + ); + } + let ToolSpec::Namespace(namespace) = v2.visible_spec(MULTI_AGENT_V2_NAMESPACE) else { + panic!("expected {MULTI_AGENT_V2_NAMESPACE} namespace"); }; + let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) = + namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent" + ) + }) + else { + panic!("expected spawn_agent in {MULTI_AGENT_V2_NAMESPACE} namespace"); + }; + let spawn_agent_description = spawn_agent.description.as_str(); assert!(!spawn_agent_description.contains("max_concurrent_threads_per_session")); assert!(spawn_agent_description.contains( "Note that passing `fork_turns=\"none\"` will not pass any surrounding context to the spawned subagent" @@ -1183,9 +1214,11 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { }); }) .await; - direct_model_only.assert_visible_contains(&["spawn_agent", "send_message", "wait_agent"]); + direct_model_only.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); + direct_model_only.assert_visible_lacks(&["spawn_agent", "send_message", "wait_agent"]); assert_eq!( - direct_model_only.exposure("spawn_agent"), + direct_model_only + .exposure(&ToolName::namespaced(MULTI_AGENT_V2_NAMESPACE, "spawn_agent").to_string()), ToolExposure::DirectModelOnly ); } @@ -1196,9 +1229,17 @@ async fn multi_agent_v2_message_schemas_are_encrypted() { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); }) .await; + let ToolSpec::Namespace(namespace) = plan.visible_spec(MULTI_AGENT_V2_NAMESPACE) else { + panic!("expected {MULTI_AGENT_V2_NAMESPACE} namespace"); + }; for tool_name in ["spawn_agent", "send_message", "followup_task"] { - let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { - panic!("expected {tool_name} function spec"); + let Some(ResponsesApiNamespaceTool::Function(tool)) = namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == tool_name + ) + }) else { + panic!("expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace"); }; let properties = tool .parameters @@ -1464,12 +1505,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { codex_code_mode::WAIT_TOOL_NAME, "request_user_input", // Multi-agent v2 tools. - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "interrupt_agent", - "list_agents", + MULTI_AGENT_V2_NAMESPACE, // Hosted Responses tools. "web_search", "image_generation", diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs index 9c58de913b..5f984a2c4f 100644 --- a/codex-rs/core/src/turn_diff_tracker_tests.rs +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -18,7 +18,7 @@ fn git_blob_sha1_hex(data: &str) -> String { } async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta { - let cwd = PathUri::from_path(root).expect("absolute tempdir path"); + let cwd = PathUri::from_host_native_path(root).expect("absolute tempdir path"); let argv = vec!["apply_patch".to_string(), patch.to_string()]; match codex_apply_patch::maybe_parse_apply_patch_verified( &argv, diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index d13c081817..0a1f781b38 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -25,6 +25,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; const MODEL_KEY: &str = "model"; @@ -91,6 +92,7 @@ pub(crate) struct TurnMetadataState { parent_thread_id: Option, subagent_header: Option, subagent_kind: Option, + thread_source: Option, turn_id: String, sandbox: Option, enriched_workspaces: Arc>>>, @@ -108,6 +110,7 @@ impl TurnMetadataState { forked_from_thread_id: Option, parent_thread_id: Option, session_source: &SessionSource, + thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, permission_profile: &PermissionProfile, @@ -132,6 +135,7 @@ impl TurnMetadataState { parent_thread_id, subagent_header: subagent_header_value(session_source), subagent_kind: subagent_metadata_kind(session_source), + thread_source, turn_id, sandbox, enriched_workspaces: Arc::new(RwLock::new(None)), @@ -225,6 +229,7 @@ impl TurnMetadataState { parent_thread_id: self.parent_thread_id, subagent_header: self.subagent_header.clone(), subagent_kind: self.subagent_kind.clone(), + thread_source: self.thread_source.clone(), sandbox: self.sandbox.clone(), workspaces: self.current_workspaces(), turn_started_at_unix_ms: self.current_turn_started_at_unix_ms(), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 5e6d1f32bb..83649b9187 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -13,6 +13,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -187,6 +188,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -229,6 +231,7 @@ fn turn_metadata_state_includes_root_fork_lineage() { Some(source_thread_id), /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -267,6 +270,7 @@ fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -305,6 +309,7 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -349,6 +354,7 @@ fn turn_metadata_state_includes_known_parent_for_non_thread_spawn_subagents_with /*forked_from_thread_id*/ None, Some(parent_thread_id), &SessionSource::SubAgent(subagent_source), + /*thread_source*/ None, "turn-a".to_string(), cwd.clone(), &permission_profile, @@ -380,6 +386,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -409,6 +416,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -457,6 +465,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -509,6 +518,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -562,6 +572,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( agent_nickname: None, agent_role: None, }), + Some(ThreadSource::Feature("automation".to_string())), "turn-a".to_string(), cwd, &permission_profile, @@ -637,7 +648,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( Some("55555555-5555-4555-8555-555555555555") ); assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); - assert_eq!(json["thread_source"].as_str(), Some("client-supplied")); + assert_eq!(json["thread_source"].as_str(), Some("automation")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert!(json.get("request_kind").is_none()); assert!(json.get(WINDOW_ID_KEY).is_none()); @@ -650,6 +661,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let model_request_json: Value = serde_json::from_str(&model_request_header).expect("model request json"); assert_eq!(model_request_json["request_kind"].as_str(), Some("turn")); + assert_eq!( + model_request_json["thread_source"].as_str(), + Some("automation") + ); assert_eq!( model_request_json[INSTALLATION_ID_KEY].as_str(), Some("installation-a") @@ -679,6 +694,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -741,6 +757,7 @@ async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), repo_path, &permission_profile, diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 8a93a91833..04f7aeb69b 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -339,6 +339,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { | ResponseEvent::ServerModel(_) | ResponseEvent::ModelVerifications(_) | ResponseEvent::TurnModerationMetadata(_) + | ResponseEvent::SafetyBuffering(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } @@ -379,7 +380,8 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::Other => false, diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 9f1bcd31b1..956468da23 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -112,7 +112,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); assert!(response_item_records_turn_ttft( @@ -122,7 +122,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { call_id: "call-2".to_string(), name: "custom".to_string(), input: "echo hi".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); assert!(response_item_records_turn_ttft(&ResponseItem::Message { @@ -132,7 +132,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })); } @@ -145,14 +145,14 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { text: String::new(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })); assert!(!response_item_records_turn_ttft( &ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); } diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index d5b813cb42..2dc0afd265 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -228,6 +228,7 @@ impl BlockingTerminateExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) } @@ -286,17 +287,14 @@ async fn blocking_terminate_unified_process( ) -> anyhow::Result> { let (wake_tx, _wake_rx) = watch::channel(0); Ok(Arc::new( - UnifiedExecProcess::from_exec_server_started( - StartedExecProcess { - process: Arc::new(BlockingTerminateExecProcess { - process_id: process_id.to_string().into(), - terminate_started, - allow_terminate, - wake_tx, - }), - }, - SandboxType::None, - ) + UnifiedExecProcess::from_exec_server_started(StartedExecProcess { + process: Arc::new(BlockingTerminateExecProcess { + process_id: process_id.to_string().into(), + terminate_started, + allow_terminate, + wake_tx, + }), + }) .await?, )) } diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 725be9eed2..5f785adfa7 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -285,8 +285,9 @@ impl UnifiedExecProcess { &self, text: &str, ) -> Result<(), UnifiedExecError> { + let executor_reported_denial = self.state_rx.borrow().sandbox_denied; let sandbox_type = self.sandbox_type(); - if sandbox_type == SandboxType::None || !self.has_exited() { + if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) { return Ok(()); } @@ -297,7 +298,7 @@ impl UnifiedExecProcess { aggregated_output: StreamOutput::new(text.to_string()), ..Default::default() }; - if is_likely_sandbox_denied(sandbox_type, &exec_output) { + if executor_reported_denial || is_likely_sandbox_denied(sandbox_type, &exec_output) { let snippet = formatted_truncate_text( text, TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), @@ -374,10 +375,13 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, - sandbox_type: SandboxType, ) -> Result { let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); - let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); + let mut managed = Self::new( + process_handle, + SandboxType::None, + /*spawn_lifecycle*/ None, + ); let output_handles = managed.output_handles(); managed.output_task = Some(Self::spawn_exec_server_output_task( started, @@ -437,6 +441,7 @@ impl UnifiedExecProcess { exit_code, closed, failure, + sandbox_denied, } = response; for chunk in chunks { @@ -457,6 +462,12 @@ impl UnifiedExecProcess { break; } + if sandbox_denied { + let mut state = state_tx.borrow().clone(); + state.sandbox_denied = true; + let _ = state_tx.send_replace(state); + } + if exited { let state = state_tx.borrow().clone(); let _ = state_tx.send_replace(state.exited(exit_code)); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index fc5508eb80..2cb62a6d4b 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -10,6 +10,7 @@ use tokio::sync::watch; use tokio::time::Duration; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::codex_thread::BackgroundTerminalInfo; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; @@ -157,8 +158,14 @@ fn exec_server_params_for_request( tty: bool, ) -> codex_exec_server::ExecParams { let (env_policy, env) = exec_server_env_for_request(request); + // Sandbox retries reuse the unified-exec ID but start a distinct executor process. + let exec_server_process_id = if request.exec_server_sandbox.is_some() { + format!("{process_id}-{}", Uuid::new_v4()) + } else { + process_id.to_string() + }; codex_exec_server::ExecParams { - process_id: exec_server_process_id(process_id).into(), + process_id: exec_server_process_id.into(), argv: request.command.clone(), cwd: request.cwd.clone(), env_policy, @@ -166,6 +173,8 @@ fn exec_server_params_for_request( tty, pipe_stdin: false, arg0: request.arg0.clone(), + sandbox: request.exec_server_sandbox.clone(), + enforce_managed_network: request.exec_server_enforce_managed_network, } } @@ -196,10 +205,6 @@ impl Drop for InitialExecCommandGuard { } } -fn exec_server_process_id(process_id: i32) -> String { - process_id.to_string() -} - async fn unregister_network_approval_for_entry(entry: &ProcessEntry) { if let Some(network_approval) = entry.network_approval.as_ref() && let Some(session) = entry.session.upgrade() @@ -1039,7 +1044,7 @@ impl UnifiedExecProcessManager { .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; + return UnifiedExecProcess::from_exec_server_started(started).await; } // TODO(anp): Keep PathUri through the local PTY/process launch boundary. diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 5abd926c62..cd48d01c7c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -76,7 +76,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; let permission_profile = codex_protocol::models::PermissionProfile::Disabled; - let request = ExecRequest { + let mut request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone().into(), env: HashMap::from([ @@ -106,11 +106,13 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { windows_sandbox_workspace_roots: vec![cwd], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, - permission_profile, + permission_profile: permission_profile.clone(), file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let params = @@ -126,11 +128,16 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), ]) ); -} - -#[test] -fn exec_server_process_id_matches_unified_exec_process_id() { - assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321"); + request.exec_server_sandbox = Some( + codex_exec_server::FileSystemSandboxContext::from_permission_profile(permission_profile), + ); + let first = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + let second = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + assert!(first.process_id.as_str().starts_with("123-")); + assert!(second.process_id.as_str().starts_with("123-")); + assert_ne!(first.process_id, second.process_id); } #[cfg(windows)] diff --git a/codex-rs/core/src/unified_exec/process_state.rs b/codex-rs/core/src/unified_exec/process_state.rs index 267406da29..65e11b6f3e 100644 --- a/codex-rs/core/src/unified_exec/process_state.rs +++ b/codex-rs/core/src/unified_exec/process_state.rs @@ -3,6 +3,7 @@ pub(crate) struct ProcessState { pub(crate) has_exited: bool, pub(crate) exit_code: Option, pub(crate) failure_message: Option, + pub(crate) sandbox_denied: bool, } impl ProcessState { @@ -11,6 +12,7 @@ impl ProcessState { has_exited: true, exit_code, failure_message: self.failure_message.clone(), + sandbox_denied: self.sandbox_denied, } } @@ -19,6 +21,7 @@ impl ProcessState { has_exited: true, exit_code: self.exit_code, failure_message: Some(message), + sandbox_denied: self.sandbox_denied, } } } diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 37ec5d858b..db64d461ec 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -10,7 +10,6 @@ use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; -use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Arc; @@ -40,6 +39,7 @@ impl MockExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, })) } @@ -103,7 +103,7 @@ async fn remote_process( }), }; - UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should start") } @@ -190,6 +190,7 @@ async fn remote_process_waits_for_early_exit_event() { exit_code: Some(17), closed: true, failure: None, + sandbox_denied: false, }])), terminate_error: None, wake_tx: wake_tx.clone(), @@ -201,7 +202,7 @@ async fn remote_process_waits_for_early_exit_event() { let _ = wake_tx.send(1); }); - let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + let process = UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should observe early exit"); diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index 06e153da33..1a236e1aac 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -266,7 +266,7 @@ fn format_request_body_snapshot( request: &ResponsesRequest, options: &ContextSnapshotOptions, ) -> String { - let mut body = request.body_json(); + let mut body = crate::responses::strip_metadata_from_json(request.body_json()); canonicalize_json_snapshot_value(&mut body, options); serde_json::to_string_pretty(&body).expect("request body should serialize") } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index b071535c52..6a605a80b3 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -98,6 +98,35 @@ fn decode_body_bytes(body: &[u8], content_encoding: Option<&str>) -> Vec { } } +/// Returns a response item without internal transport metadata for semantic assertions. +pub fn strip_metadata(mut item: ResponseItem) -> ResponseItem { + item.clear_internal_chat_message_metadata_passthrough(); + item +} + +/// Returns response items without internal transport metadata for semantic assertions. +pub fn strip_metadata_from_items(items: &[ResponseItem]) -> Vec { + items.iter().cloned().map(strip_metadata).collect() +} + +/// Returns JSON without internal transport metadata for semantic assertions. +pub fn strip_metadata_from_json(value: Value) -> Value { + match value { + Value::Array(values) => { + Value::Array(values.into_iter().map(strip_metadata_from_json).collect()) + } + Value::Object(mut map) => { + map.remove("internal_chat_message_metadata_passthrough"); + Value::Object( + map.into_iter() + .map(|(key, value)| (key, strip_metadata_from_json(value))) + .collect(), + ) + } + value => value, + } +} + impl ResponsesRequest { pub fn body_json(&self) -> Value { let body = decode_body_bytes( @@ -684,7 +713,7 @@ pub fn user_message_item(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -1092,11 +1121,17 @@ pub async fn mount_compact_user_history_with_summary_sequence( ) }) .collect::>(); - // Append a synthetic compaction item as the newest item. - output.push(serde_json::json!({ + let compaction_turn_id = body_json["client_metadata"]["turn_id"].as_str(); + // Match Responses API: generated compaction items inherit the compact request turn. + let mut compaction_item = serde_json::json!({ "type": "compaction", "encrypted_content": summary_text, - })); + }); + if let Some(turn_id) = compaction_turn_id { + compaction_item["internal_chat_message_metadata_passthrough"] = + serde_json::json!({ "turn_id": turn_id }); + } + output.push(compaction_item); ResponseTemplate::new(200) .insert_header("content-type", "application/json") .set_body_json(serde_json::json!({ "output": output })) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index d843e0097b..8784a4d4a0 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -998,7 +998,7 @@ impl TestCodexHarness { ) -> Result<()> { let abs_path = self.path_abs(rel); if let Some(parent) = abs_path.parent() { - let parent_uri = PathUri::from_path(&parent)?; + let parent_uri = PathUri::from_host_native_path(&parent)?; self.test .fs() .create_directory( @@ -1008,7 +1008,7 @@ impl TestCodexHarness { ) .await?; } - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; self.test .fs() .write_file( @@ -1022,7 +1022,7 @@ impl TestCodexHarness { pub async fn read_file_text(&self, rel: impl AsRef) -> Result { let path = self.path_abs(rel); - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; Ok(self .test .fs() @@ -1032,7 +1032,7 @@ impl TestCodexHarness { pub async fn create_dir_all(&self, rel: impl AsRef) -> Result<()> { let path = self.path_abs(rel); - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; self.test .fs() .create_directory( diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index cf9b9a939f..2f323e0dd3 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -141,7 +141,7 @@ async fn responses_stream_includes_subagent_header_on_review() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session @@ -273,7 +273,7 @@ async fn responses_stream_includes_subagent_header_on_other() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session @@ -391,7 +391,7 @@ async fn responses_respects_model_info_overrides_from_config() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session diff --git a/codex-rs/core/tests/suite/agent_execution.rs b/codex-rs/core/tests/suite/agent_execution.rs index f073d70089..69db20cc57 100644 --- a/codex-rs/core/tests/suite/agent_execution.rs +++ b/codex-rs/core/tests/suite/agent_execution.rs @@ -2,7 +2,7 @@ use anyhow::Result; use codex_features::Feature; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; @@ -15,6 +15,7 @@ use std::time::Duration; const FIRST_PROMPT: &str = "spawn the first worker"; const FIRST_TASK: &str = "first worker task"; const SECOND_TASK: &str = "second worker task"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; fn body_contains(request: &wiremock::Request, text: &str) -> bool { serde_json::from_slice::(&request.body) @@ -47,7 +48,12 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> |request: &wiremock::Request| body_contains(request, FIRST_PROMPT), sse(vec![ ev_response_created("first-response"), - ev_function_call("first-call", "spawn_agent", &first_args), + ev_function_call_with_namespace( + "first-call", + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &first_args, + ), ev_completed("first-response"), ]), ) @@ -63,7 +69,12 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> }, sse(vec![ ev_response_created("first-worker-response"), - ev_function_call("second-call", "spawn_agent", &second_args), + ev_function_call_with_namespace( + "second-call", + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &second_args, + ), ev_completed("first-worker-response"), ]), ) diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index c405e35995..9e75e36e99 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -1,6 +1,10 @@ use anyhow::Result; use codex_features::Feature; use codex_protocol::config_types::ServiceTier; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -10,12 +14,89 @@ use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; 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; use serde_json::Value; use std::time::Duration; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + }) + .with_model("gpt-5.3-codex"); + let test = builder.build_with_websocket_server(&server).await?; + + test.submit_turn("non-lite turn").await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "lite turn".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(server.handshakes().len(), 1); + let connection = server.single_connection(); + assert_eq!(connection.len(), 3); + let non_lite_turn = connection + .get(1) + .expect("missing non-lite turn request") + .body_json(); + let lite_turn = connection + .get(2) + .expect("missing lite turn request") + .body_json(); + + assert_eq!(non_lite_turn["model"].as_str(), Some("gpt-5.3-codex")); + assert_eq!(lite_turn["model"].as_str(), Some("gpt-5.4")); + assert!( + non_lite_turn + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| !tools.is_empty()) + ); + assert_eq!(lite_turn.get("previous_response_id"), None); + assert_eq!(lite_turn.get("tools"), None); + assert_eq!(lite_turn.get("instructions"), None); + let additional_tools = lite_turn + .get("input") + .and_then(Value::as_array) + .and_then(|input| input.first()) + .filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .and_then(|item| item.get("tools")) + .and_then(Value::as_array) + .expect("lite turn should start with an additional_tools item"); + assert!(!additional_tools.is_empty()); + + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); @@ -175,11 +256,14 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); let call_id = "shell-command-call"; + let mut shell_command_call = ev_shell_command_call(call_id, "echo websocket"); + shell_command_call["item"]["internal_chat_message_metadata_passthrough"] = + serde_json::json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_shell_command_call(call_id, "echo websocket"), + shell_command_call, ev_completed("resp-1"), ], vec![ diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 498a666079..9094461aae 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -141,8 +141,8 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> { agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move { let agents_md = cwd.join("AGENTS.md"); let override_md = cwd.join("AGENTS.override.md"); - let agents_md_uri = PathUri::from_path(&agents_md)?; - let override_md_uri = PathUri::from_path(&override_md)?; + let agents_md_uri = PathUri::from_host_native_path(&agents_md)?; + let override_md_uri = PathUri::from_host_native_path(&override_md)?; fs.write_file(&agents_md_uri, b"base doc".to_vec(), /*sandbox*/ None) .await?; fs.write_file( @@ -177,8 +177,8 @@ async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Res .with_workspace_setup(|cwd, fs| async move { let agents_dir = cwd.join("AGENTS.md"); let fallback = cwd.join("WORKFLOW.md"); - let agents_dir_uri = PathUri::from_path(&agents_dir)?; - let fallback_uri = PathUri::from_path(&fallback)?; + let agents_dir_uri = PathUri::from_host_native_path(&agents_dir)?; + let fallback_uri = PathUri::from_host_native_path(&fallback)?; fs.create_directory( &agents_dir_uri, CreateDirectoryOptions { recursive: true }, @@ -220,10 +220,10 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { let root_agents = root.join("AGENTS.md"); let git_marker = root.join(".git"); let nested_agents = nested.join("AGENTS.md"); - let nested_uri = PathUri::from_path(&nested)?; - let root_agents_uri = PathUri::from_path(&root_agents)?; - let git_marker_uri = PathUri::from_path(&git_marker)?; - let nested_agents_uri = PathUri::from_path(&nested_agents)?; + let nested_uri = PathUri::from_host_native_path(&nested)?; + let root_agents_uri = PathUri::from_host_native_path(&root_agents)?; + let git_marker_uri = PathUri::from_host_native_path(&git_marker)?; + let nested_agents_uri = PathUri::from_host_native_path(&nested_agents)?; fs.create_directory( &nested_uri, @@ -363,7 +363,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu let mut builder = test_codex() .with_home(home) .with_workspace_setup(|cwd, fs| async move { - let agents_md_uri = PathUri::from_path(cwd.join("AGENTS.md"))?; + let agents_md_uri = PathUri::from_host_native_path(cwd.join("AGENTS.md"))?; fs.write_file( &agents_md_uri, b"project doc".to_vec(), @@ -420,7 +420,8 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { .with_home(Arc::clone(&home)) .with_user_instructions_provider(provider.clone()) .with_workspace_setup(|cwd, fs| async move { - let project_agents_uri = PathUri::from_path(cwd.join(GLOBAL_AGENTS_FILENAME))?; + let project_agents_uri = + PathUri::from_host_native_path(cwd.join(GLOBAL_AGENTS_FILENAME))?; fs.write_file( &project_agents_uri, PROJECT_INSTRUCTIONS.as_bytes().to_vec(), @@ -505,7 +506,7 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re let mut builder = test_codex() .with_home(Arc::clone(&home)) .with_workspace_setup(|cwd, fs| async move { - let agents_md_uri = PathUri::from_path(cwd.join("AGENTS.md"))?; + let agents_md_uri = PathUri::from_host_native_path(cwd.join("AGENTS.md"))?; fs.write_file( &agents_md_uri, PROJECT_INSTRUCTIONS.as_bytes().to_vec(), @@ -534,7 +535,7 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re )?; test.fs() .write_file( - &PathUri::from_path(&project_source)?, + &PathUri::from_host_native_path(&project_source)?, NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(), /*sandbox*/ None, ) @@ -631,7 +632,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho .with_user_instructions_provider(provider.clone()) .with_workspace_setup(|cwd, fs| async move { fs.write_file( - &PathUri::from_path(cwd.join(GLOBAL_AGENTS_FILENAME))?, + &PathUri::from_host_native_path(cwd.join(GLOBAL_AGENTS_FILENAME))?, b"remote project instructions".to_vec(), /*sandbox*/ None, ) @@ -658,7 +659,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho }, TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_path(local_root.path())?, + cwd: PathUri::from_host_native_path(local_root.path())?, }, ], thread_extension_init: Default::default(), @@ -671,7 +672,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho vec![ PathUri::from_abs_path(&global_source), PathUri::from_abs_path(&remote_source), - PathUri::from_path(&local_source)?, + PathUri::from_host_native_path(&local_source)?, ] ); @@ -684,7 +685,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho )?; test.fs() .write_file( - &PathUri::from_path(test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME))?, + &PathUri::from_host_native_path(test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME))?, b"new remote project instructions".to_vec(), /*sandbox*/ None, ) @@ -712,7 +713,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho vec![ PathUri::from_abs_path(&global_source), PathUri::from_abs_path(&remote_source), - PathUri::from_path(&local_source)?, + PathUri::from_host_native_path(&local_source)?, ] ); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 2527e4cb93..c348dd6f9e 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1329,7 +1329,7 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest config.cwd = config.cwd.join("subdir"); }) .with_workspace_setup(|cwd, fs| async move { - let cwd_uri = PathUri::from_path(&cwd)?; + let cwd_uri = PathUri::from_host_native_path(&cwd)?; fs.create_directory( &cwd_uri, CreateDirectoryOptions { recursive: true }, @@ -1337,8 +1337,8 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest ) .await?; let repo_root = cwd.parent().expect("nested cwd should have parent"); - let git_uri = PathUri::from_path(repo_root.join(".git"))?; - let repo_file_uri = PathUri::from_path(repo_root.join("repo.txt"))?; + let git_uri = PathUri::from_host_native_path(repo_root.join(".git"))?; + let repo_file_uri = PathUri::from_host_native_path(repo_root.join("repo.txt"))?; fs.write_file( &git_uri, b"gitdir: /tmp/fake-worktree\n".to_vec(), @@ -1586,7 +1586,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let shared_cwd_uri = PathUri::from_path(&shared_cwd)?; + let shared_cwd_uri = PathUri::from_host_native_path(&shared_cwd)?; let _ = fs::remove_dir_all(shared_cwd.as_path()); test.fs() .remove( @@ -1689,7 +1689,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re assert_eq!( test.fs() .read_file_text( - &PathUri::from_path(shared_cwd.join(file_name))?, + &PathUri::from_host_native_path(shared_cwd.join(file_name))?, /*sandbox*/ None, ) .await?, @@ -1859,7 +1859,7 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> let harness = apply_patch_harness_with(|builder| { builder.with_workspace_setup(|cwd, fs| async move { - let binary_path_uri = PathUri::from_path(cwd.join("binary.dat"))?; + let binary_path_uri = PathUri::from_host_native_path(cwd.join("binary.dat"))?; fs.write_file( &binary_path_uri, vec![0xff, 0xfe, 0xfd], diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index fe543a875b..aa2026be1c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -66,6 +66,7 @@ use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_failed; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::responses_metadata as test_responses_metadata; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; @@ -186,7 +187,66 @@ fn assert_codex_client_metadata( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn non_openai_responses_requests_omit_item_turn_metadata() { +async fn openai_stateless_responses_requests_preserve_item_turn_metadata_across_turns() { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp1"), + ev_assistant_message("msg-1", "first answer"), + ev_completed("resp1"), + ]), + sse(vec![ev_response_created("resp2"), ev_completed("resp2")]), + ], + ) + .await; + let test = test_codex().build(&server).await.unwrap(); + + test.submit_turn("turn one").await.unwrap(); + test.submit_turn("turn two").await.unwrap(); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let first = requests[0].body_json(); + let second = requests[1].body_json(); + let first_turn_id = first["client_metadata"]["turn_id"] + .as_str() + .expect("first request should include turn id"); + let second_turn_id = second["client_metadata"]["turn_id"] + .as_str() + .expect("second request should include turn id"); + assert_ne!(first_turn_id, second_turn_id); + + let first_input = first["input"].as_array().expect("first input"); + let second_input = second["input"].as_array().expect("second input"); + assert_eq!(&second_input[..first_input.len()], first_input.as_slice()); + for item in first_input { + assert_eq!( + item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str(), + Some(first_turn_id) + ); + } + + let item_turn_id = |text: &str| { + second_input + .iter() + .find(|item| { + item["content"].as_array().is_some_and(|content| { + content + .iter() + .any(|content_item| content_item["text"].as_str() == Some(text)) + }) + }) + .and_then(|item| item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str()) + }; + assert_eq!(item_turn_id("turn one"), Some(first_turn_id)); + assert_eq!(item_turn_id("first answer"), Some(first_turn_id)); + assert_eq!(item_turn_id("turn two"), Some(second_turn_id)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_openai_responses_requests_omit_item_passthrough_metadata() { let server = MockServer::start().await; let response_mock = mount_sse_once( &server, @@ -230,8 +290,9 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() { assert!(!input.is_empty(), "request should include input items"); for item in input { assert!( - item.get("metadata").is_none(), - "input item should omit metadata: {item}" + item.get("internal_chat_message_metadata_passthrough") + .is_none(), + "input item should omit internal chat message metadata passthrough: {item}" ); assert!( item.get("id").is_none(), @@ -516,6 +577,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": convo_id, "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", "instructions": "be nice", @@ -536,7 +598,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_user_json = serde_json::to_value(&prior_user).unwrap(); writeln!( @@ -558,7 +620,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed system instruction".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_system_json = serde_json::to_value(&prior_system).unwrap(); writeln!( @@ -580,7 +642,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed assistant message".to_string(), }], phase: Some(MessagePhase::Commentary), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_item_json = serde_json::to_value(&prior_item).unwrap(); writeln!( @@ -723,15 +785,17 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { call_id: "legacy-js-call".to_string(), name: "js_repl".to_string(), input: "console.log('legacy image flow')".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + let thread_id = ThreadId::default(); let rollout = vec![ RolloutLine { timestamp: "2024-01-01T00:00:00.000Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { - id: ThreadId::default(), + session_id: thread_id.into(), + id: thread_id, parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), @@ -754,7 +818,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { call_id: "legacy-js-call".to_string(), name: None, output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -767,7 +831,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, ]; @@ -857,15 +921,17 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { async fn resume_replays_image_tool_outputs_with_detail() { skip_if_no_network!(); - let image_url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA="; + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let function_call_id = "view-image-call"; let custom_call_id = "js-repl-call"; + let thread_id = ThreadId::default(); let rollout = vec![ RolloutLine { timestamp: "2024-01-01T00:00:00.000Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { - id: ThreadId::default(), + session_id: thread_id.into(), + id: thread_id, parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), @@ -883,9 +949,9 @@ async fn resume_replays_image_tool_outputs_with_detail() { id: None, name: "view_image".to_string(), namespace: None, - arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), + arguments: "{\"path\":\"/tmp/example.png\"}".to_string(), call_id: function_call_id.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -899,7 +965,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -910,7 +976,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { call_id: custom_call_id.to_string(), name: "js_repl".to_string(), input: "console.log('image flow')".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -925,7 +991,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, ]; @@ -1174,7 +1240,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); let mut stream = client_session @@ -1379,6 +1445,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("Failed to load CodexAuth") @@ -2788,7 +2855,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { text: "content".into(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::Message { id: Some("message-id".into()), @@ -2797,7 +2864,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { text: "message".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::WebSearchCall { id: Some("web-search-id".into()), @@ -2806,7 +2873,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { query: Some("weather".into()), queries: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCall { id: Some("function-id".into()), @@ -2814,13 +2881,13 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { namespace: None, arguments: "{}".into(), call_id: "function-call-id".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCallOutput { id: None, call_id: "function-call-id".into(), output: FunctionCallOutputPayload::from_text("ok".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::LocalShellCall { id: Some("local-shell-id".into()), @@ -2833,7 +2900,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::CustomToolCall { id: Some("custom-tool-id".into()), @@ -2841,14 +2908,14 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { call_id: "custom-tool-call-id".into(), name: "custom_tool".into(), input: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::CustomToolCallOutput { id: None, call_id: "custom-tool-call-id".into(), name: None, output: FunctionCallOutputPayload::from_text("ok".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); let mut stream = client_session @@ -3605,7 +3672,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let tail_len = r3_tail_expected.as_array().unwrap().len(); let actual_tail = &r3_input_array[r3_input_array.len() - tail_len..]; assert_eq!( - serde_json::Value::Array(actual_tail.to_vec()), + strip_metadata_from_json(serde_json::Value::Array(actual_tail.to_vec())), r3_tail_expected, "request 3 tail mismatch", ); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1ccbd302d8..d35714ea45 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1564,10 +1564,13 @@ async fn responses_websocket_uses_incremental_create_on_prefix() { async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() { skip_if_no_network!(); + let mut first_output_item = ev_assistant_message("msg-1", "assistant output"); + first_output_item["item"]["internal_chat_message_metadata_passthrough"] = + json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + first_output_item, ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -1577,9 +1580,11 @@ async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_c let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); let prompt_one = prompt_with_input(vec![message_item("hello")]); + let mut prior_assistant_output = assistant_message_item("msg-1", "assistant output"); + prior_assistant_output.set_turn_id_if_missing("turn-123"); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + prior_assistant_output, message_item("second"), ]); let first_responses_metadata = turn_metadata(&harness, Some("turn-123")); @@ -2059,7 +2064,7 @@ fn message_item(text: &str) -> ResponseItem { role: "user".into(), content: vec![ContentItem::InputText { text: text.into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -2069,7 +2074,7 @@ fn assistant_message_item(id: &str, text: &str) -> ResponseItem { role: "assistant".into(), content: vec![ContentItem::OutputText { text: text.into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0a765a0e26..14bee0fb8d 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2681,7 +2681,7 @@ async fn code_mode_can_output_images_via_global_helper() -> Result<()> { &server, "use exec to return images", r#" -image("data:image/png;base64,AAA"); +image("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="); "#, ) .await?; @@ -2706,7 +2706,7 @@ image("data:image/png;base64,AAA"); items[1], serde_json::json!({ "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "detail": "high" }), ); @@ -2715,17 +2715,14 @@ image("data:image/png;base64,AAA"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { +async fn code_mode_replaces_malformed_image() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn_with_config( + let (_test, second_mock) = run_code_mode_turn( &server, "use exec to return an image", r#"image("data:image/png;base64,AAA");"#, - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, ) .await?; @@ -2746,7 +2743,7 @@ async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result<()> { +async fn code_mode_resizes_explicit_original_image() -> Result<()> { skip_if_no_network!(Ok(())); let original_dimensions = (6401, 100); @@ -2772,12 +2769,7 @@ async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result "use exec to return a large original-detail image", &code, "gpt-5.3-codex", - |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); - }, + |_| {}, ) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 92ec66e161..dec106d0d8 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,6 @@ use anyhow::Result; use anyhow::anyhow; +use codex_core::CodexThread; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; @@ -35,6 +36,7 @@ use core_test_support::responses; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; @@ -92,6 +94,48 @@ const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; 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."; +async fn build_auto_compaction_disabled_codex(server: &MockServer) -> TestCodex { + let mut model_provider = non_openai_model_provider(server); + model_provider.stream_max_retries = Some(0); + test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(100); + config.model_auto_compact_token_limit = Some(90); + let _ = config.features.disable(Feature::AutoCompaction); + }) + .build(server) + .await + .expect("build codex") +} + +async fn submit_context_window_exceeded_turn(codex: &Arc, text: &str) { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await + .expect("submit context window exceeded turn"); + let error_message = wait_for_event_match(codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + assert!( + error_message.contains("ran out of room in the model's context window"), + "expected context window exceeded message, got {error_message}" + ); +} + fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { ev_function_call( call_id, @@ -1993,12 +2037,12 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { text: remote_summary.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = @@ -2276,6 +2320,93 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_comp_hash_model_switch_compaction() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.3-codex"; + let next_model = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_optional_comp_hash(previous_model, Some("hash-a")), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), + ], + }, + ) + .await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "after switch"), + ev_completed_with_tokens("r2", /*total_tokens*/ 100), + ]), + ], + ) + .await; + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.disable(Feature::AutoCompaction); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit second user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 2, + "disabled auto-compaction should skip compaction on a comp-hash model switch" + ); + let first = requests[0].body_json(); + let second = requests[1].body_json(); + assert_eq!(first["model"].as_str(), Some(previous_model)); + assert_eq!(second["model"].as_str(), Some(next_model)); + assert!(second.to_string().contains("before switch")); + assert!(second.to_string().contains("after switch")); + assert!( + !body_contains_text(&second.to_string(), SUMMARIZATION_PROMPT), + "disabled auto-compaction should preserve history instead of requesting a summary" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { skip_if_no_network!(); @@ -3655,6 +3786,45 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_mid_turn_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let over_limit_tokens = 100 * 95 / 100 + 1; + let first_turn = sse(vec![ + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r1", over_limit_tokens), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + first_turn, + sse_failed( + "response-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + let test = build_auto_compaction_disabled_codex(&server).await; + + submit_context_window_exceeded_turn(&test.codex, FUNCTION_CALL_LIMIT_MSG).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2); + let continuation_request = &requests[1]; + continuation_request.function_call_output(DUMMY_CALL_ID); + assert!( + !body_contains_text( + &continuation_request.body_json().to_string(), + SUMMARIZATION_PROMPT + ), + "disabled auto-compaction should continue without a compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_clamps_config_limit_to_context_window() { skip_if_no_network!(); @@ -3993,12 +4163,12 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = @@ -4121,12 +4291,12 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = @@ -4233,7 +4403,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ) .await .expect("override thread settings"); - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); codex .submit(Op::UserInput { @@ -4488,6 +4658,44 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_pre_turn_compaction() { + 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", /*total_tokens*/ 500), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + first_turn, + sse_failed( + "response-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + let test = build_auto_compaction_disabled_codex(&server).await; + + test.submit_turn("USER_ONE") + .await + .expect("submit first turn"); + submit_context_window_exceeded_turn(&test.codex, "USER_TWO").await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2); + let second_request_body = requests[1].body_json().to_string(); + assert!(second_request_body.contains("USER_TWO")); + assert!( + !body_contains_text(&second_request_body, SUMMARIZATION_PROMPT), + "disabled auto-compaction should sample without a pre-turn compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 77561093c6..ececb02b22 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -159,7 +159,7 @@ fn compacted_summary_only_output(summary: &str) -> Vec { vec![ResponseItem::Compaction { id: None, encrypted_content: summary_with_prefix(summary), - metadata: None, + internal_chat_message_metadata_passthrough: None, }] } @@ -333,7 +333,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let compact_mock = responses::mount_compact_json_once( harness.server(), @@ -2361,7 +2361,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -2370,7 +2370,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text: "COMPACTED_ASSISTANT_NOTE".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -2512,12 +2512,12 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text: stale_developer_message.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -2655,12 +2655,12 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text: stale_developer_message.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -4067,7 +4067,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: summary_with_prefix("REMOTE_SUMMARY_ONLY"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let compact_mock = responses::mount_compact_json_once( harness.server(), diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 432e5815aa..6f56ce016d 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -924,6 +924,7 @@ fn normalize_value(value: Value) -> Value { Value::Array(values) => Value::Array(values.into_iter().map(normalize_value).collect()), Value::Object(map) => Value::Object( map.into_iter() + .filter(|(key, _value)| key != "internal_chat_message_metadata_passthrough") .map(|(key, value)| (key, normalize_value(value))) .collect(), ), diff --git a/codex-rs/core/tests/suite/extension_sandbox.rs b/codex-rs/core/tests/suite/extension_sandbox.rs index e4c01557de..aa835fa9ac 100644 --- a/codex-rs/core/tests/suite/extension_sandbox.rs +++ b/codex-rs/core/tests/suite/extension_sandbox.rs @@ -42,9 +42,11 @@ use wiremock::matchers::path; 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, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; +const TINY_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; fn image_generation_extensions(auth: &CodexAuth) -> Arc> { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); @@ -148,7 +150,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { .and(path("/v1/images/edits")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "created": 1, - "data": [{"b64_json": "cG5n"}], + "data": [{"b64_json": TINY_PNG_BASE64}], }))) .expect(1) .mount(&server) @@ -299,7 +301,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { let output = request.function_call_output(image_call_id); let image = &output["output"][0]; assert_eq!(image["type"], "input_image"); - assert_eq!(image["image_url"], "data:image/png;base64,cG5n"); + assert_eq!(image["image_url"], TINY_PNG_DATA_URL); Ok(()) } diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index d51ba7d2f6..cc1d4e9177 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::parse_turn_item; @@ -196,7 +198,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { test.config.clone(), InitialHistory::Resumed(ResumedHistory { conversation_id: test.session_configured.thread_id, - history: source_items.clone(), + history: Arc::new(source_items.clone()), rollout_path: None, }), /*thread_source*/ None, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index ae87350c39..c1da262033 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -17,6 +17,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::local_selections; @@ -30,6 +31,85 @@ use std::os::unix::fs::PermissionsExt; use std::time::Duration; use tempfile::TempDir; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_session_prewarms_and_is_reused_for_first_review() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tool_args = json!({ + "cmd": "true", + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian approval routing.", + }) + .to_string(); + let server = start_websocket_server(vec![ + vec![vec![ev_response_created("warm-1"), ev_completed("warm-1")]], + vec![vec![ev_response_created("warm-2"), ev_completed("warm-2")]], + vec![vec![ + ev_response_created("approval-request"), + ev_function_call("approval-call", "exec_command", &tool_args), + ev_completed("approval-request"), + ]], + vec![vec![ + ev_response_created("guardian-review"), + ev_completed("guardian-review"), + ]], + ]) + .await; + let mut builder = test_codex().with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }); + + let test = builder.build_with_websocket_server(&server).await?; + let (first, second) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!( + server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0), + server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0) + ) + }) + .await?; + let prewarm_requests = [first.body_json(), second.body_json()]; + let guardian_prewarm = prewarm_requests + .iter() + .find(|request| { + request["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") + }) + .expect("guardian startup prewarm request"); + assert_eq!(guardian_prewarm["generate"].as_bool(), Some(false)); + let guardian_thread_id = guardian_prewarm["client_metadata"]["thread_id"] + .as_str() + .expect("guardian thread id"); + + test.codex + .submit( + vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }] + .into(), + ) + .await?; + let guardian_review = tokio::time::timeout( + Duration::from_secs(5), + server.wait_for_request(/*connection_index*/ 3, /*request_index*/ 0), + ) + .await? + .body_json(); + assert_eq!( + guardian_review["client_metadata"]["x-openai-subagent"].as_str(), + Some("guardian") + ); + assert_eq!( + guardian_review["client_metadata"]["thread_id"].as_str(), + Some(guardian_thread_id) + ); + assert_eq!(guardian_review.get("generate"), None); + + test.codex.shutdown_and_wait().await?; + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 2b18559075..d84a5501cc 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -178,10 +178,10 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; - assert_eq!(actual, expected); + assert_eq!(responses::strip_metadata(actual), expected); Ok(()) } @@ -200,7 +200,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> .. } = test_codex().build(&server).await?; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=".to_string(); + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(); let response = sse(vec![ ev_response_created("resp-1"), @@ -269,10 +269,10 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; - assert_eq!(actual, expected); + assert_eq!(responses::strip_metadata(actual), expected); Ok(()) } diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index 44f2f87c3e..b86862edc2 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -35,10 +35,6 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> .features .enable(Feature::CodeModeOnly) .expect("test config should allow feature update"); - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); config.code_mode.direct_only_tool_namespaces = vec![SEARCH_CALENDAR_NAMESPACE.to_string()]; }); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 492f309795..a9298ec6d9 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -104,6 +104,7 @@ mod review; mod rmcp_client; mod rollout_budget; mod rollout_list_find; +mod safety_buffering; mod safety_check_downgrade; mod search_tool; mod shell_command; diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 3ee758372d..0c6a87605d 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -22,6 +22,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::submit_thread_settings; @@ -36,6 +37,13 @@ use tokio::time::sleep; const CHILD_MODEL: &str = "test-multi-agent-child"; const ROOT_MODEL: &str = "test-multi-agent-root"; const ROOT_PROMPT: &str = "spawn a child"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; +const UNSUPPORTED_CODE_MODE_WARNING: &str = "does not advertise Code Mode support"; + +struct RemoteModelResponse { + body: Value, + warnings: Vec, +} fn remote_model(slug: &str) -> ModelInfo { ModelInfo { @@ -81,10 +89,10 @@ async fn wait_for_model_available(manager: &SharedModelsManager, slug: &str) -> } } -async fn response_body_for_remote_model( +async fn response_for_remote_model( remote_model: ModelInfo, configure: impl FnOnce(&mut Config) + Send + 'static, -) -> Result { +) -> Result { let server = responses::start_mock_server().await; let model_slug = remote_model.slug.clone(); let models_mock = mount_models_once( @@ -133,12 +141,28 @@ async fn response_body_for_remote_model( thread_settings: Default::default(), }) .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; + let mut warnings = Vec::new(); + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Warning(warning) => warnings.push(warning.message), + EventMsg::TurnComplete(_) => break, + _ => {} + } + } - Ok(response_mock.single_request().body_json()) + Ok(RemoteModelResponse { + body: response_mock.single_request().body_json(), + warnings, + }) +} + +async fn response_body_for_remote_model( + remote_model: ModelInfo, + configure: impl FnOnce(&mut Config) + Send + 'static, +) -> Result { + Ok(response_for_remote_model(remote_model, configure) + .await? + .body) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -180,6 +204,117 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { ] ); + let unsupported_model = remote_model("test-tool-mode-unsupported"); + let unsupported_response = response_for_remote_model(unsupported_model, |config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + }) + .await?; + assert!( + tool_names(&unsupported_response.body) + .iter() + .any(|name| name == codex_code_mode::PUBLIC_TOOL_NAME) + ); + assert_eq!( + unsupported_response + .warnings + .iter() + .filter(|warning| warning.contains(UNSUPPORTED_CODE_MODE_WARNING)) + .count(), + 1 + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unsupported_code_mode_warning_is_emitted_each_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let model_slug = "test-tool-mode-warning-each-turn"; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model(model_slug)], + }, + ) + .await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let models_manager = test.thread_manager.get_models_manager(); + let available_model = wait_for_model_available(&models_manager, model_slug).await; + assert_eq!(available_model.model, model_slug); + assert_eq!(models_mock.requests().len(), 1); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some(model_slug.to_string()), + ..Default::default() + }, + ) + .await?; + + let mut warning_counts = Vec::new(); + for prompt in ["first turn", "second turn"] { + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let mut warning_count = 0; + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Warning(warning) + if warning.message.contains(UNSUPPORTED_CODE_MODE_WARNING) => + { + warning_count += 1; + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + warning_counts.push(warning_count); + } + + assert_eq!(warning_counts, vec![1, 1]); + assert_eq!(response_mock.requests().len(), 2); + Ok(()) } @@ -201,7 +336,7 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { .expect("test config should allow feature update"); }) .await?; - assert!(tool_names(&v2_body).contains(&"send_message".to_string())); + assert!(tool_names(&v2_body).contains(&MULTI_AGENT_V2_NAMESPACE.to_string())); let mut disabled_model = remote_model("test-multi-agent-disabled"); disabled_model.multi_agent_version = Some(MultiAgentVersion::Disabled); @@ -215,7 +350,12 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { let disabled_tools = tool_names(&disabled_body); assert!(disabled_tools.iter().all(|name| !matches!( name.as_str(), - "multi_agent_v1" | "spawn_agent" | "send_message" | "wait_agent" | "list_agents" + "multi_agent_v1" + | MULTI_AGENT_V2_NAMESPACE + | "spawn_agent" + | "send_message" + | "wait_agent" + | "list_agents" ))); Ok(()) @@ -298,7 +438,7 @@ async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> .expect("expected response request") .body_json(), ) - .contains(&"send_message".to_string()), + .contains(&MULTI_AGENT_V2_NAMESPACE.to_string()), ), (1, Some(MultiAgentVersion::V2), true) ); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index ef7a305bd7..4688434a4d 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -506,7 +506,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< let _ = models_manager .list_models(RefreshStrategy::OnlineIfUncached) .await; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); test.codex diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index ba91f2fd31..21ef31c492 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -19,6 +19,7 @@ use pretty_assertions::assert_eq; use serde_json::Value; const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const NO_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive."; const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; fn developer_texts(input: &[Value]) -> Vec<&str> { @@ -66,7 +67,7 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { let server = start_mock_server().await; let responses = mount_sse_sequence( &server, - (1..=3) + (1..=5) .map(|index| { sse(vec![ ev_response_created(&format!("resp-{index}")), @@ -82,18 +83,24 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); }) .build(&server) .await?; submit_turn(&test.codex, "turn one", /*mode*/ None).await?; - assert_eq!(test.codex.config_snapshot().await.multi_agent_mode, None); + assert_eq!( + test.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::ExplicitRequestOnly + ); submit_turn(&test.codex, "turn two", Some(MultiAgentMode::Proactive)).await?; submit_turn(&test.codex, "turn three", /*mode*/ None).await?; + submit_turn(&test.codex, "turn four", Some(MultiAgentMode::None)).await?; + submit_turn(&test.codex, "turn five", /*mode*/ None).await?; + + assert_eq!( + test.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); let requests = responses.requests(); let inputs = requests @@ -103,6 +110,8 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { let first = developer_texts(&inputs[0]); let second = developer_texts(&inputs[1]); let third = developer_texts(&inputs[2]); + let fourth = developer_texts(&inputs[3]); + let fifth = developer_texts(&inputs[4]); assert_eq!( ( @@ -128,12 +137,102 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { ), (2, 1, 1) ); + assert_eq!( + ( + count_containing(&fourth, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&fourth, NO_SPAWN_TEXT), + count_containing(&fourth, PROACTIVE_TEXT), + count_containing(&fourth, NO_MODE_TEXT), + ), + (3, 1, 1, 1) + ); + assert_eq!( + ( + count_containing(&fifth, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&fifth, NO_SPAWN_TEXT), + count_containing(&fifth, PROACTIVE_TEXT), + count_containing(&fifth, NO_MODE_TEXT), + ), + (3, 1, 1, 1) + ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<()> { +async fn multi_agent_mode_none_omits_instructions_and_survives_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=2) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let initial = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + submit_turn(&initial.codex, "before resume", Some(MultiAgentMode::None)).await?; + assert_eq!( + initial.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); + drop(initial); + + let mut resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + submit_turn(&resumed.codex, "after resume", /*mode*/ None).await?; + + assert_eq!( + resumed.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + for request in requests { + let input = request.input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + count_containing(&texts, NO_MODE_TEXT), + ), + (0, 0, 0, 0) + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_mode_applies_without_usage_hint_text() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -148,21 +247,21 @@ async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<( .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + config.multi_agent_v2.root_agent_usage_hint_text = None; }) .build(&server) .await?; - submit_turn(&test.codex, "hello", /*mode*/ None).await?; + submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?; let input = responses.single_request().input(); let texts = developer_texts(&input); assert_eq!( ( count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), count_containing(&texts, PROACTIVE_TEXT), ), - (1, 1, 0) + (1, 1) ); Ok(()) @@ -175,7 +274,7 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result let server = start_mock_server().await; let responses = mount_sse_sequence( &server, - (1..=4) + (1..=2) .map(|index| { sse(vec![ ev_response_created(&format!("resp-{index}")), @@ -214,18 +313,14 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; - submit_turn( - &resumed.codex, - "after resume", - Some(MultiAgentMode::Proactive), - ) - .await?; + submit_turn(&resumed.codex, "after resume", /*mode*/ None).await?; + + assert_eq!( + resumed.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::Proactive + ); let requests = responses.requests(); let resumed_input = requests[1].input(); @@ -236,108 +331,14 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result count_containing(&texts, NO_SPAWN_TEXT), count_containing(&texts, PROACTIVE_TEXT), ), - (2, 1, 1) - ); - - let resumed_rollout_path = resumed - .session_configured - .rollout_path - .clone() - .expect("resumed rollout path"); - let resumed_home = resumed.home.clone(); - drop(resumed); - let mut same_mode_resume_builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - }); - let resumed_same_mode = same_mode_resume_builder - .resume(&server, resumed_home, resumed_rollout_path) - .await?; - submit_turn( - &resumed_same_mode.codex, - "after same-mode resume", - /*mode*/ None, - ) - .await?; - - assert_eq!( - resumed_same_mode - .codex - .config_snapshot() - .await - .multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); - let requests = responses.requests(); - let resumed_same_mode_input = requests[2].input(); - let texts = developer_texts(&resumed_same_mode_input); - assert_eq!( - ( - count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), - count_containing(&texts, PROACTIVE_TEXT), - ), - (2, 1, 1) - ); - - let resumed_same_mode_rollout_path = resumed_same_mode - .session_configured - .rollout_path - .clone() - .expect("same-mode resumed rollout path"); - let resumed_same_mode_home = resumed_same_mode.home.clone(); - drop(resumed_same_mode); - let mut disabled_mode_resume_builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - }); - let resumed_disabled_mode = disabled_mode_resume_builder - .resume( - &server, - resumed_same_mode_home, - resumed_same_mode_rollout_path, - ) - .await?; - submit_turn( - &resumed_disabled_mode.codex, - "after disabled-mode resume", - /*mode*/ None, - ) - .await?; - - assert_eq!( - resumed_disabled_mode - .codex - .config_snapshot() - .await - .multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); - let requests = responses.requests(); - let resumed_disabled_mode_input = requests[3].input(); - let texts = developer_texts(&resumed_disabled_mode_input); - assert_eq!( - ( - count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), - count_containing(&texts, PROACTIVE_TEXT), - ), - (3, 2, 1) + (1, 0, 1) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { +async fn multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -346,21 +347,13 @@ async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Resul sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), ) .await; - let test = test_codex() - .with_config(|config| { - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - }) - .build(&server) - .await?; + let test = test_codex().build(&server).await?; submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?; assert_eq!( test.codex.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); let input = responses.single_request().input(); let texts = developer_texts(&input); diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 14db16c60a..f476fb7287 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -74,7 +74,7 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index edc14cead0..9068f5a566 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -22,7 +22,9 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodex; @@ -142,18 +144,29 @@ async fn run_extract_turn(test: &TestCodex, server: &MockServer) -> Result io::R let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -109,6 +110,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 198db86d88..b3420feabe 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -12,9 +12,15 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::responses::ResponseMock; +use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -29,6 +35,10 @@ use wiremock::MockServer; const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test"; const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample"; const SAMPLE_PLUGIN_DESCRIPTION: &str = "inspect sample data"; +const SAMPLE_PLUGIN_APP_NAMESPACE: &str = "mcp__codex_apps__google_calendar"; +const SAMPLE_PLUGIN_MCP_NAMESPACE: &str = "mcp__sample"; +const PLUGIN_APP_SEARCH_CALL_ID: &str = "plugin-app-search"; +const PLUGIN_MCP_SEARCH_CALL_ID: &str = "plugin-mcp-search"; fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf { home.path().join("plugins/cache/test/sample/local") @@ -145,21 +155,53 @@ async fn build_apps_enabled_plugin_test_codex( .expect("create new conversation")) } -fn tool_names(body: &serde_json::Value) -> Vec { - body.get("tools") - .and_then(serde_json::Value::as_array) - .map(|tools| { - tools - .iter() - .filter_map(|tool| { - tool.get("name") - .or_else(|| tool.get("type")) - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) - .collect() - }) - .unwrap_or_default() +async fn mount_plugin_tool_search_turn(server: &MockServer) -> ResponseMock { + mount_sse_sequence( + server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_tool_search_call( + PLUGIN_APP_SEARCH_CALL_ID, + &serde_json::json!({"query": "create calendar event"}), + ), + ev_tool_search_call( + PLUGIN_MCP_SEARCH_CALL_ID, + &serde_json::json!({"query": "echo"}), + ), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await +} + +fn assert_plugin_provenance(tool: &serde_json::Value) { + let description = tool + .get("description") + .and_then(serde_json::Value::as_str) + .expect("plugin tool description should be present"); + assert!( + description.contains("This tool is part of plugin `sample`."), + "expected plugin provenance in tool description: {description:?}" + ); +} + +fn searched_plugin_tools( + request: &ResponsesRequest, +) -> (Option, Option) { + let app_output = request.tool_search_output(PLUGIN_APP_SEARCH_CALL_ID); + let mcp_output = request.tool_search_output(PLUGIN_MCP_SEARCH_CALL_ID); + ( + namespace_child_tool( + &app_output, + SAMPLE_PLUGIN_APP_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + ) + .cloned(), + namespace_child_tool(&mcp_output, SAMPLE_PLUGIN_MCP_NAMESPACE, "echo").cloned(), + ) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -237,11 +279,7 @@ async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; - let mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - ) - .await; + let mock = mount_plugin_tool_search_turn(&server).await; let codex_home = Arc::new(TempDir::new()?); let rmcp_test_server_bin = match stdio_server_bin() { @@ -275,7 +313,8 @@ async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let request = mock.single_request(); + let requests = mock.requests(); + let request = &requests[0]; let developer_messages = request.message_input_texts("developer"); assert!( developer_messages @@ -295,28 +334,18 @@ async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> .any(|text| text.contains("Apps from this plugin")), "expected visible plugin app guidance: {developer_messages:?}" ); - let request_body = request.body_json(); - let request_tools = tool_names(&request_body); assert!( - request_tools - .iter() - .any(|name| name == "mcp__codex_apps__google_calendar"), - "expected plugin app tools to become visible for this turn: {request_tools:?}" + request + .tool_by_name(SAMPLE_PLUGIN_MCP_NAMESPACE, "echo") + .is_none(), + "plugin MCP tool should not leak into the request for ChatGPT auth" ); + let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]); + let calendar_tool = calendar_tool.expect("plugin app tool should be searchable"); + assert_plugin_provenance(&calendar_tool); assert!( - request.tool_by_name("mcp__sample", "echo").is_none(), - "expected plugin MCP tool to be suppressed for ChatGPT auth" - ); - let calendar_tool = request - .tool_by_name("mcp__codex_apps__google_calendar", "_create_event") - .expect("plugin app tool should be present"); - let calendar_description = calendar_tool - .get("description") - .and_then(serde_json::Value::as_str) - .expect("plugin app tool description should be present"); - assert!( - calendar_description.contains("This tool is part of plugin `sample`."), - "expected plugin app provenance in tool description: {calendar_description:?}" + echo_tool.is_none(), + "plugin MCP tool should be suppressed for ChatGPT auth" ); Ok(()) @@ -327,11 +356,7 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; - let mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - ) - .await; + let mock = mount_plugin_tool_search_turn(&server).await; let codex_home = Arc::new(TempDir::new()?); let rmcp_test_server_bin = match stdio_server_bin() { @@ -365,7 +390,8 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let request = mock.single_request(); + let requests = mock.requests(); + let request = &requests[0]; let developer_messages = request.message_input_texts("developer"); assert!( developer_messages @@ -379,25 +405,13 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> .any(|text| text.contains("Apps from this plugin")), "expected plugin app guidance: {developer_messages:?}" ); - let request_body = request.body_json(); - let request_tools = tool_names(&request_body); + let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]); assert!( - request_tools - .iter() - .any(|name| name == "mcp__codex_apps__google_calendar"), - "expected plugin app tools to become visible for this turn: {request_tools:?}" - ); - let echo_tool = request - .tool_by_name("mcp__sample", "echo") - .expect("plugin MCP tool should remain present"); - let echo_description = echo_tool - .get("description") - .and_then(serde_json::Value::as_str) - .expect("plugin MCP tool description should be present"); - assert!( - echo_description.contains("This tool is part of plugin `sample`."), - "expected plugin MCP provenance in tool description: {echo_description:?}" + calendar_tool.is_some(), + "plugin app tool should be searchable" ); + let echo_tool = echo_tool.expect("plugin MCP tool should remain searchable"); + assert_plugin_provenance(&echo_tool); Ok(()) } @@ -406,11 +420,7 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> async fn explicit_plugin_mentions_use_mcp_for_api_key_dual_surface_plugins() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - ) - .await; + let mock = mount_plugin_tool_search_turn(&server).await; let codex_home = Arc::new(TempDir::new()?); let rmcp_test_server_bin = match stdio_server_bin() { @@ -454,7 +464,8 @@ async fn explicit_plugin_mentions_use_mcp_for_api_key_dual_surface_plugins() -> .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let request = mock.single_request(); + let requests = mock.requests(); + let request = &requests[0]; let developer_messages = request.message_input_texts("developer"); assert!( developer_messages @@ -474,25 +485,19 @@ async fn explicit_plugin_mentions_use_mcp_for_api_key_dual_surface_plugins() -> .any(|text| text.contains("Apps from this plugin")), "expected plugin app guidance to be suppressed for API-key auth: {developer_messages:?}" ); - let request_body = request.body_json(); - let request_tools = tool_names(&request_body); assert!( - !request_tools - .iter() - .any(|name| name == "mcp__codex_apps__google_calendar"), - "expected plugin app tools to be hidden for API-key auth: {request_tools:?}" + request + .tool_by_name(SAMPLE_PLUGIN_APP_NAMESPACE, SEARCH_CALENDAR_CREATE_TOOL) + .is_none(), + "plugin app tool should not leak into the request for API-key auth" ); - let echo_tool = request - .tool_by_name("mcp__sample", "echo") - .expect("plugin MCP tool should be present"); - let echo_description = echo_tool - .get("description") - .and_then(serde_json::Value::as_str) - .expect("plugin MCP tool description should be present"); + let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]); assert!( - echo_description.contains("This tool is part of plugin `sample`."), - "expected plugin MCP provenance in tool description: {echo_description:?}" + calendar_tool.is_none(), + "plugin app tool should be hidden for API-key auth" ); + let echo_tool = echo_tool.expect("plugin MCP tool should be searchable"); + assert_plugin_provenance(&echo_tool); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index a455512321..90d48d77a4 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -25,6 +25,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; @@ -54,6 +55,13 @@ fn text_user_input_parts(texts: Vec) -> serde_json::Value { }) } +fn assert_eq_without_metadata(left: serde_json::Value, right: serde_json::Value) { + assert_eq!( + strip_metadata_from_json(left), + strip_metadata_from_json(right) + ); +} + fn assert_default_env_context(text: &str, cwd: &str) { assert_env_context_fragment(text); assert!( @@ -384,16 +392,18 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests Some("input_text"), "expected environment context bundled after UI message in cached contextual message" ); - assert_eq!(input1[2], text_user_input("hello 1".to_string())); + assert_eq_without_metadata(input1[2].clone(), text_user_input("hello 1".to_string())); let body2 = req2.single_request().body_json(); let input2 = body2["input"].as_array().expect("input array"); - assert_eq!( - &input2[..input1.len()], - input1.as_slice(), - "expected cached prefix to be reused" + assert_eq_without_metadata( + serde_json::Value::Array(input2[..input1.len()].to_vec()), + serde_json::Value::Array(input1.to_vec()), + ); + assert_eq_without_metadata( + input2[input1.len()].clone(), + text_user_input("hello 2".to_string()), ); - assert_eq!(input2[input1.len()], text_user_input("hello 2".to_string())); Ok(()) } @@ -523,7 +533,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an expected_body2.push(expected_permissions_msg_2); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); - assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); + assert_eq_without_metadata( + body2["input"].clone(), + serde_json::Value::Array(expected_body2), + ); Ok(()) } @@ -810,7 +823,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res expected_body2.push(expected_settings_update_msg); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); - assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); + assert_eq_without_metadata( + body2["input"].clone(), + serde_json::Value::Array(expected_body2), + ); Ok(()) } @@ -818,7 +834,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - use pretty_assertions::assert_eq; let server = start_mock_server().await; let req1 = mount_sse_once( @@ -940,7 +955,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); - assert_eq!(body1["input"], expected_input_1); + assert_eq_without_metadata(body1["input"].clone(), expected_input_1); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ @@ -949,7 +964,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a expected_user_message_1, expected_user_message_2, ]); - assert_eq!(body2["input"], expected_input_2); + assert_eq_without_metadata(body2["input"].clone(), expected_input_2); Ok(()) } @@ -1079,7 +1094,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); - assert_eq!(body1["input"], expected_input_1); + assert_eq_without_metadata(body1["input"].clone(), expected_input_1); let body1_input = body1["input"].as_array().expect("input array"); let expected_settings_update_msg = body2["input"][body1_input.len()].clone(); @@ -1118,7 +1133,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu expected_env_update_msg, expected_user_message_2, ]); - assert_eq!(body2["input"], expected_input_2); + assert_eq_without_metadata(body2["input"].clone(), expected_input_2); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 5883a8564b..74aeece04d 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -8,6 +8,7 @@ use codex_home::CodexHomeUserInstructionsProvider; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use core_test_support::responses::strip_metadata; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -49,9 +50,12 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text: "hello from debug prompt".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; - assert_eq!(input.last(), Some(&expected_user_message)); + assert_eq!( + input.last().cloned().map(strip_metadata), + Some(expected_user_message) + ); assert!(input.iter().any(|item| { let ResponseItem::Message { content, .. } = item else { return false; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index dd97e5a5d3..1a95223bff 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2350,7 +2350,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge role: "user".to_string(), content: vec![ContentItem::InputText { text: user_turn }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::ResponseItem(ResponseItem::Message { id: None, @@ -2359,7 +2359,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge text: assistant_turn, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), ] }) diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 67e5668f45..2df03f92a8 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -26,6 +26,8 @@ use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -49,9 +51,13 @@ use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_env; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use futures::SinkExt; +use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::process::Command; @@ -59,6 +65,12 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; async fn unified_exec_test(server: &wiremock::MockServer) -> Result { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; @@ -75,6 +87,7 @@ async fn submit_turn_with_approval_and_environments( test: &TestCodex, prompt: &str, environments: Vec, + approval_policy: AskForApproval, ) -> Result<()> { let turn_environment_selections = codex_protocol::protocol::TurnEnvironmentSelections::new( test.config.cwd.clone(), @@ -91,7 +104,7 @@ async fn submit_turn_with_approval_and_environments( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(turn_environment_selections), - approval_policy: Some(AskForApproval::OnRequest), + approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { @@ -160,7 +173,7 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> { let file_system = test_env.environment().get_filesystem(); let file_path_abs = test_env.cwd().join("remote-test-env-ok"); - let file_path_uri = PathUri::from_path(&file_path_abs)?; + let file_path_uri = PathUri::from_host_native_path(&file_path_abs)?; let payload = b"remote-test-env-ok".to_vec(); file_system @@ -301,8 +314,152 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn deferred_executor_reaches_model_before_remote_environment_is_ready() -> Result<()> { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; +async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor"); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + const CALL_ID: &str = "remote-sandbox-denial"; + const CONTENTS: &str = "remote sandbox retry succeeded"; + + let server = start_mock_server().await; + let test = unified_exec_test(&server).await?; + let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); + let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs(); + let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs(); + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; + let target_uri = PathUri::from_host_native_path(&target_path)?; + test.fs() + .create_directory( + &remote_cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + let command = format!("printf {CONTENTS:?} > {target_path:?} && cat {target_path:?}"); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-remote-denial-1"), + ev_function_call( + CALL_ID, + "exec_command", + &json!({ + "shell": "/bin/sh", + "cmd": command, + "login": false, + "yield_time_ms": 5_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-remote-denial-1"), + ]), + sse(vec![ + ev_response_created("resp-remote-denial-2"), + ev_assistant_message("msg-remote-denial", "done"), + ev_completed("resp-remote-denial-2"), + ]), + ], + ) + .await; + + submit_turn_with_approval_and_environments( + &test, + "retry a sandbox-denied command in the remote environment", + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&remote_cwd), + }], + AskForApproval::OnFailure, + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::ExecApprovalRequest(approval) = event else { + panic!("expected remote sandbox approval before completion: {event:?}"); + }; + assert_eq!(approval.call_id, CALL_ID); + assert_eq!( + approval.environment_id.as_deref(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert_eq!( + approval.reason.as_deref(), + Some("command failed; retry without sandbox?") + ); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert!( + response_mock + .function_call_output_text(CALL_ID) + .is_some_and(|output| output.contains(CONTENTS)), + "approved retry should return the remote command output" + ); + assert_eq!( + test.fs() + .read_file_text(&target_uri, /*sandbox*/ None) + .await?, + CONTENTS + ); + + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &remote_cwd_uri, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> { let server = start_mock_server().await; let response_mock = mount_sse_once( &server, @@ -313,23 +470,216 @@ async fn deferred_executor_reaches_model_before_remote_environment_is_ready() -> ]), ) .await; + let mut builder = test_codex().with_config(|config| { + assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + }); + let test = builder.build(&server).await?; + + test.submit_turn("report the environment").await?; + + let user_context = response_mock.single_request().message_input_texts("user"); + assert_eq!( + user_context + .iter() + .filter(|text| text.contains("")) + .count(), + 1 + ); + + Ok(()) +} + +async fn read_exec_server_json(websocket: &mut WebSocketStream) -> Value { + loop { + match timeout(Duration::from_secs(5), websocket.next()) + .await + .expect("websocket read should not time out") + .expect("websocket should stay open") + .expect("websocket frame should read") + { + Message::Text(text) => { + return serde_json::from_str(text.as_ref()).expect("valid JSON-RPC message"); + } + Message::Binary(bytes) => { + return serde_json::from_slice(bytes.as_ref()).expect("valid JSON-RPC message"); + } + Message::Ping(_) | Message::Pong(_) => {} + other => panic!("expected JSON-RPC message, got {other:?}"), + } + } +} + +async fn serve_environment_info(listener: TcpListener) { + let (stream, _) = listener.accept().await.expect("connection"); + let mut websocket = accept_async(stream).await.expect("websocket handshake"); + + let initialize = read_exec_server_json(&mut websocket).await; + assert_eq!(initialize["method"], "initialize"); + websocket + .send(Message::Text( + json!({ + "id": initialize["id"], + "result": { "sessionId": "test-session" } + }) + .to_string() + .into(), + )) + .await + .expect("initialize response"); + let initialized = read_exec_server_json(&mut websocket).await; + assert_eq!(initialized["method"], "initialized"); + + let info = read_exec_server_json(&mut websocket).await; + assert_eq!(info["method"], "environment/info"); + websocket + .send(Message::Text( + json!({ + "id": info["id"], + "result": { "shell": { "name": "zsh", "path": "/bin/zsh" } } + }) + .to_string() + .into(), + )) + .await + .expect("environment info response"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_executor_updates_model_context_after_startup() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let server = start_mock_server().await; + let user_input_call_id = "wait-for-startup"; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + user_input_call_id, + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after startup?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the test." + }, { + "label": "No", + "description": "Stop the test." + }] + }] + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + "update-plan", + "update_plan", + &json!({ + "explanation": "Continue after startup.", + "plan": [{"step": "Finish", "status": "completed"}] + }) + .to_string(), + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-3", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; let mut builder = test_codex() .with_exec_server_url(format!("ws://{}", listener.local_addr()?)) .with_config(|config| { assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok() + ); }); - - let test = tokio::time::timeout(Duration::from_secs(5), builder.build(&server)) + let test = timeout(Duration::from_secs(5), builder.build(&server)) .await .context("thread startup should not wait for the remote environment")??; - tokio::time::timeout( - Duration::from_secs(5), - test.submit_turn("respond before the environment is ready"), - ) - .await - .context("turn should reach the model before the remote environment is ready")??; - response_mock.single_request(); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "wait for the environment".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + serve_environment_info(listener).await; + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "continue".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + assert!( + requests[0] + .message_input_texts("user") + .iter() + .any(|text| text.contains("starting")) + ); + let ready_user_context = requests[1].message_input_texts("user"); + assert_eq!( + ready_user_context + .iter() + .filter(|text| text.contains("zsh")) + .count(), + 1 + ); + let final_user_context = requests[2].message_input_texts("user"); + assert_eq!( + final_user_context + .iter() + .filter(|text| text.contains("starting")) + .count(), + 1 + ); + assert_eq!( + final_user_context + .iter() + .filter(|text| text.contains("zsh")) + .count(), + 1 + ); + Ok(()) } @@ -451,8 +801,8 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { )) .abs(); let remote_marker_name = "marker.txt"; - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; - let remote_marker_uri = PathUri::from_path(remote_cwd.join(remote_marker_name))?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; + let remote_marker_uri = PathUri::from_host_native_path(remote_cwd.join(remote_marker_name))?; test.fs() .create_directory( &remote_cwd_uri, @@ -550,7 +900,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result let local_write_root = local_cwd.path().join(relative_write_root); let local_target_path = local_cwd.path().join(relative_target_path); fs::create_dir(&local_write_root)?; - let remote_write_root_uri = PathUri::from_path(&remote_write_root)?; + let remote_write_root_uri = PathUri::from_host_native_path(&remote_write_root)?; test.fs() .create_directory( &remote_write_root_uri, @@ -630,6 +980,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result cwd: PathUri::from_abs_path(&remote_cwd), }, ], + AskForApproval::OnRequest, ) .await?; @@ -689,7 +1040,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result assert_eq!( test.fs() .read_file_text( - &PathUri::from_path(&remote_target_path)?, + &PathUri::from_host_native_path(&remote_target_path)?, /*sandbox*/ None, ) .await?, @@ -733,7 +1084,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -778,7 +1129,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< let remote_contents = test .fs() .read_file_text( - &PathUri::from_path(remote_cwd.join(file_name))?, + &PathUri::from_host_native_path(remote_cwd.join(file_name))?, /*sandbox*/ None, ) .await?; @@ -823,7 +1174,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -837,7 +1188,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let target_path_uri = PathUri::from_path(&target_path)?; + let target_path_uri = PathUri::from_host_native_path(&target_path)?; let _ = fs::remove_file(&target_path); test.fs() .remove( @@ -911,6 +1262,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in local environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-local").await; @@ -930,6 +1282,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in remote environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-remote").await; @@ -954,6 +1307,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch again in remote environment", environments, + AskForApproval::OnRequest, ) .await?; wait_for_completion_without_patch_approval(&test).await; @@ -1008,7 +1362,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -1063,7 +1417,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm let remote_contents = test .fs() .read_file_text( - &PathUri::from_path(remote_cwd.join(file_name))?, + &PathUri::from_host_native_path(remote_cwd.join(file_name))?, /*sandbox*/ None, ) .await?; @@ -1101,8 +1455,8 @@ async fn remote_test_env_sandboxed_read_allows_readable_root() -> Result<()> { let allowed_dir = PathBuf::from(format!("/tmp/codex-remote-readable-{}", std::process::id())); let file_path = allowed_dir.join("note.txt"); - let allowed_dir_uri = PathUri::from_path(&allowed_dir)?; - let file_path_uri = PathUri::from_path(&file_path)?; + let allowed_dir_uri = PathUri::from_host_native_path(&allowed_dir)?; + let file_path_uri = PathUri::from_host_native_path(&file_path)?; file_system .create_directory( &allowed_dir_uri, @@ -1162,7 +1516,7 @@ async fn remote_test_env_sandboxed_read_rejects_symlink_parent_dotdot_escape() - ))?; let requested_path = - PathUri::from_path(allowed_dir.join("link").join("..").join("secret.txt"))?; + PathUri::from_host_native_path(allowed_dir.join("link").join("..").join("secret.txt"))?; let sandbox = read_only_sandbox(allowed_dir.clone()); let error = match file_system.read_file(&requested_path, Some(&sandbox)).await { Ok(_) => anyhow::bail!("read should fail after path normalization"), @@ -1210,7 +1564,7 @@ async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> { let sandbox = workspace_write_sandbox(allowed_dir.clone()); file_system .remove( - &PathUri::from_path(&symlink_path)?, + &PathUri::from_host_native_path(&symlink_path)?, RemoveOptions { recursive: false, force: false, @@ -1228,13 +1582,16 @@ async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> { .is_ok(); assert!(!symlink_exists); let outside = file_system - .read_file_text(&PathUri::from_path(&outside_file)?, /*sandbox*/ None) + .read_file_text( + &PathUri::from_host_native_path(&outside_file)?, + /*sandbox*/ None, + ) .await?; assert_eq!(outside, "outside"); file_system .remove( - &PathUri::from_path(&root)?, + &PathUri::from_host_native_path(&root)?, RemoveOptions { recursive: true, force: true, @@ -1276,8 +1633,8 @@ async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> { let sandbox = workspace_write_sandbox(allowed_dir.clone()); file_system .copy( - &PathUri::from_path(&source_symlink)?, - &PathUri::from_path(&copied_symlink)?, + &PathUri::from_host_native_path(&source_symlink)?, + &PathUri::from_host_native_path(&copied_symlink)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -1310,7 +1667,7 @@ async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> { file_system .remove( - &PathUri::from_path(&root)?, + &PathUri::from_host_native_path(&root)?, RemoveOptions { recursive: true, force: true, diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index b137d5bc73..4d2a38f6f8 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -54,8 +54,72 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) } +fn additional_tools(body: &Value) -> Result<&[Value]> { + body["input"] + .as_array() + .context("Responses request input should be an array")? + .first() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .context("Responses request should start with additional_tools")?["tools"] + .as_array() + .map(Vec::as_slice) + .context("additional_tools tools should be an array") +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> Result<()> { +async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + }) + .with_config(|config| { + config.base_instructions = Some("test instructions".to_string()); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let body = response_mock.single_request().body_json(); + assert!(body.get("instructions").is_none()); + assert!(body.get("tools").is_none()); + + let input = body["input"] + .as_array() + .context("Responses request input should be an array")?; + assert_eq!(input[0]["type"], "additional_tools"); + assert_eq!(input[0]["role"], "developer"); + assert_eq!( + input[1], + serde_json::json!({ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "test instructions", + }], + }) + ); + + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_prepares_images() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -68,6 +132,7 @@ async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> ) .await; let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + let remote_image_url = "https://example.com/image.png"; let mut builder = test_codex().with_model_info_override("gpt-5.4", |model_info| { model_info.use_responses_lite = true; configure_image_capable_model(model_info); @@ -76,10 +141,16 @@ async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> test.codex .submit(Op::UserInput { - items: vec![UserInput::Image { - image_url: image_url.to_string(), - detail: Some(ImageDetail::Original), - }], + items: vec![ + UserInput::Image { + image_url: image_url.to_string(), + detail: Some(ImageDetail::Original), + }, + UserInput::Image { + image_url: remote_image_url.to_string(), + detail: Some(ImageDetail::High), + }, + ], final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -92,20 +163,27 @@ async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> .await; let request = response_mock.single_request(); - let input = request.input(); - let image = input - .iter() - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .find(|item| item.get("type").and_then(Value::as_str) == Some("input_image")) - .context("request should contain an image")?; + let user_content = request + .input() + .into_iter() + .rev() + .find(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .and_then(|item| item.get("content").and_then(Value::as_array).cloned()) + .context("request should contain user content")?; assert_eq!( - image, - &serde_json::json!({ - "type": "input_image", - "image_url": image_url - }) + user_content, + vec![ + serde_json::json!({ + "type": "input_image", + "image_url": image_url + }), + serde_json::json!({ + "type": "input_text", + "text": "image content omitted because remote image URLs are not supported" + }), + ] ); + assert!(!request.body_json().to_string().contains(remote_image_url)); Ok(()) } @@ -144,17 +222,10 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res request.header(RESPONSES_LITE_HEADER).as_deref(), Some("true") ); - request - .tool_by_name("web", "run") - .context("Responses Lite should expose standalone web search")?; - request - .tool_by_name("image_gen", "imagegen") - .context("Responses Lite should expose standalone image generation")?; - let body = request.body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); @@ -242,9 +313,8 @@ async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Re test.submit_turn("Do not use hosted tools").await?; let body = response_mock.single_request().body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 073e6d8b49..50395b2ecb 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -1,5 +1,7 @@ #![allow(clippy::unwrap_used)] +use std::sync::Arc; + use codex_core::NewThread; use codex_login::CodexAuth; use codex_protocol::ThreadId; @@ -51,7 +53,7 @@ fn resume_history( InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![ + history: Arc::new(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), trace_id: None, @@ -75,7 +77,7 @@ fn resume_history( duration_ms: None, time_to_first_token_ms: None, })), - ], + ]), rollout_path: Some(rollout_path.to_path_buf()), }) } diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 6b0f8552a2..af44baf6ca 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -511,6 +511,7 @@ async fn review_input_isolated_from_parent_history() { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": convo_id, "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", "cwd": ".", @@ -531,7 +532,7 @@ async fn review_input_isolated_from_parent_history() { text: "parent: earlier user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let user_json = serde_json::to_value(&user).unwrap(); let user_line = serde_json::json!({ @@ -551,7 +552,7 @@ async fn review_input_isolated_from_parent_history() { text: "parent: assistant reply".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let assistant_json = serde_json::to_value(&assistant).unwrap(); let assistant_line = serde_json::json!({ diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index dde226a194..8000ed1c00 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -24,10 +24,11 @@ use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::Environment; use codex_exec_server::HttpRequestParams; -use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; +use codex_mcp::SandboxState; use codex_models_manager::manager::RefreshStrategy; +use codex_utils_path_uri::LegacyAppPathString; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; @@ -298,7 +299,7 @@ fn stdio_transport_with_cwd( args: Vec::new(), env, env_vars, - cwd, + cwd: cwd.map(|cwd| LegacyAppPathString::from_path(&cwd)), } } @@ -540,20 +541,33 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let server = responses::start_mock_server().await; let call_id = "call-123"; + let search_call_id = "search-rmcp-echo"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); - let call_mock = mount_sse_once( + mount_sse_once( &server, responses::sse(vec![ responses::ev_response_created("resp-1"), + responses::ev_tool_search_call( + search_call_id, + &json!({"query": "echo message and environment data"}), + ), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let call_mock = mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-2"), responses::ev_function_call_with_namespace( call_id, &namespace, "echo", "{\"message\":\"ping\"}", ), - responses::ev_completed("resp-1"), + responses::ev_completed("resp-2"), ]), ) .await; @@ -561,7 +575,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { &server, responses::sse(vec![ responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), - responses::ev_completed("resp-2"), + responses::ev_completed("resp-3"), ]), ) .await; @@ -646,13 +660,14 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let output_item = final_mock.single_request().function_call_output(call_id); - let request = call_mock.single_request(); + let search_output = call_mock + .single_request() + .tool_search_output(search_call_id); assert!( - request.tool_by_name(&namespace, "echo").is_some(), - "direct MCP tool should be sent as a namespace child tool: {:?}", - request.body_json() + responses::namespace_child_tool(&search_output, &namespace, "echo").is_some(), + "tool_search should surface the RMCP echo tool: {search_output:?}" ); + let output_item = final_mock.single_request().function_call_output(call_id); let output_text = output_item .get("output") @@ -734,7 +749,7 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R let fixture = test_codex() .with_workspace_setup(|cwd, fs| async move { let configured_cwd = cwd.join("mcp-configured-cwd"); - let configured_cwd_uri = PathUri::from_path(&configured_cwd)?; + let configured_cwd_uri = PathUri::from_host_native_path(&configured_cwd)?; fs.create_directory( &configured_cwd_uri, CreateDirectoryOptions { recursive: true }, @@ -861,7 +876,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); - let call_mock = mount_sse_once( + mount_sse_once( &server, responses::sse(vec![ responses::ev_response_created("resp-1"), @@ -904,13 +919,6 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> ) .await?; - let request = call_mock.single_request(); - assert!( - request.tool_by_name(&namespace, "sandbox_meta").is_some(), - "direct MCP tool should be sent as a namespace child tool: {:?}", - request.body_json() - ); - let output_item = final_mock.single_request().function_call_output(call_id); let output_text = output_item .get("output") @@ -926,13 +934,16 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let sandbox_meta = meta .get(MCP_SANDBOX_STATE_META_CAPABILITY) .expect("sandbox state metadata should be present"); - assert_eq!(sandbox_meta.get("sandboxPolicy"), None); - let expected_sandbox_cwd = PathUri::from_abs_path(&fixture.config.cwd).to_string(); + let sandbox_state: SandboxState = serde_json::from_value(sandbox_meta.clone())?; assert_eq!( - sandbox_meta.get("sandboxCwd").and_then(Value::as_str), - Some(expected_sandbox_cwd.as_str()) + sandbox_state, + SandboxState { + permission_profile: PermissionProfile::read_only(), + codex_linux_sandbox_exe: fixture.config.codex_linux_sandbox_exe.clone(), + sandbox_cwd: PathUri::from_abs_path(&fixture.config.cwd), + use_legacy_landlock: false, + } ); - assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false))); server.verify().await; @@ -1455,10 +1466,6 @@ async fn stdio_image_responses_resize_large_image() -> anyhow::Result<()> { let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); insert_mcp_server( config, server_name, diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 61c6dfe424..6aa211bcc2 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -10,7 +10,7 @@ use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; @@ -25,12 +25,16 @@ use std::time::Duration; use test_case::test_case; use tokio::time::timeout; -const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig { - limit_tokens: 100, - reminder_interval_tokens: 25, - sampling_token_weight: 1.0, - prefill_token_weight: 1.0, -}; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; + +fn rollout_budget() -> RolloutBudgetConfig { + RolloutBudgetConfig { + limit_tokens: 100, + reminder_at_remaining_tokens: vec![75, 50, 25], + sampling_token_weight: 1.0, + prefill_token_weight: 1.0, + } +} fn rollout_budget_texts(request: &ResponsesRequest) -> Vec { request @@ -51,7 +55,7 @@ fn wire_request_contains(request: &wiremock::Request, text: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> { +async fn adds_weighted_initial_and_threshold_reminders() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -83,7 +87,7 @@ async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> { config.rollout_budget = Some(RolloutBudgetConfig { sampling_token_weight: 2.0, prefill_token_weight: 0.5, - ..ROLLOUT_BUDGET + ..rollout_budget() }); }) .build(&server) @@ -128,7 +132,12 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { |request: &wiremock::Request| wire_request_contains(request, ROOT_PROMPT), sse(vec![ ev_response_created("root-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed_with_tokens("root-1", /*total_tokens*/ 10), ]), ) @@ -171,7 +180,7 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { .features .enable(Feature::MultiAgentV2) .expect("test config should allow multi-agent v2"); - config.rollout_budget = Some(ROLLOUT_BUDGET); + config.rollout_budget = Some(rollout_budget()); }) .build(&server) .await?; @@ -218,8 +227,8 @@ async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { .with_config(|config| { config.rollout_budget = Some(RolloutBudgetConfig { limit_tokens: 30, - reminder_interval_tokens: 10, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![20, 10], + ..rollout_budget() }); }) .build(&server) @@ -282,25 +291,20 @@ async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: b ]) }; let responses = mount_sse_sequence(&server, vec![compact_response]).await; - let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); - model_provider.base_url = Some(format!("{}/v1", server.uri())); - model_provider.supports_websockets = false; - if !remote_v2 { - model_provider.name = "OpenAI-compatible test provider".to_string(); - } let test = test_codex() .with_config(move |config| { - config.model_provider = model_provider; config.rollout_budget = Some(RolloutBudgetConfig { limit_tokens: 10, - reminder_interval_tokens: 5, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![5], + ..rollout_budget() }); if remote_v2 { config .features .enable(Feature::RemoteCompactionV2) .expect("test config should allow remote compaction v2"); + } else { + config.model_provider.name = "OpenAI-compatible test provider".to_string(); } }) .build(&server) @@ -354,8 +358,8 @@ async fn restates_the_current_remainder_after_compaction() -> Result<()> { .with_config(move |config| { config.model_provider = model_provider; config.rollout_budget = Some(RolloutBudgetConfig { - reminder_interval_tokens: 50, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() }); }) .build(&server) @@ -409,8 +413,8 @@ async fn restates_the_current_remainder_after_rollback() -> Result<()> { let test = test_codex() .with_config(|config| { config.rollout_budget = Some(RolloutBudgetConfig { - reminder_interval_tokens: 50, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() }); }) .build(&server) diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 680301d9f9..1461c1af35 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -42,6 +42,7 @@ fn write_minimal_rollout_with_id_at_path(file: &Path, id: Uuid) { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": id, "id": id, "timestamp": "2024-01-01T00:00:00Z", "cwd": ".", diff --git a/codex-rs/core/tests/suite/safety_buffering.rs b/codex-rs/core/tests/suite/safety_buffering.rs new file mode 100644 index 0000000000..6e41b23766 --- /dev/null +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -0,0 +1,74 @@ +use anyhow::Ok; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SafetyBufferingEvent; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_once; +use core_test_support::responses::sse; +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 core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; + +const FASTER_MODEL: &str = "faster-model"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_safety_buffering_with_the_requested_model() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut created = ev_response_created("resp-1"); + created["safety_buffering"] = json!({ + "use_cases": ["cyber"], + "reasons": ["policy-check"], + }); + mount_response_once( + &server, + sse_response(sse(vec![created, ev_completed("resp-1")])) + .insert_header("x-codex-safety-buffering-enabled", "true") + .insert_header("x-codex-safety-buffering-faster-model", FASTER_MODEL), + ) + .await; + + let test = test_codex().build(&server).await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Check this request".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let event = wait_for_event_match(&test.codex, |event| match event { + EventMsg::SafetyBuffering(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!( + event, + SafetyBufferingEvent { + model: test.session_configured.model.clone(), + use_cases: vec!["cyber".to_string()], + reasons: vec!["policy-check".to_string()], + show_buffering_ui: true, + faster_model: Some(FASTER_MODEL.to_string()), + } + ); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 7228547725..af67827927 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -4,7 +4,6 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; -use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; @@ -30,7 +29,6 @@ use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; -use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::configure_search_capable_apps; use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; @@ -180,7 +178,7 @@ async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { +async fn small_app_tool_sets_are_deferred_by_default() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -195,13 +193,7 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { ) .await; - let mut builder = - configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -232,30 +224,42 @@ async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> R let server = start_mock_server().await; let apps_server = AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Direct).await?; + let search_call_id = "app-only-search"; let call_id = "app-only-direct-call"; let mock = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), + ev_tool_search_call( + search_call_id, + &json!({ + "query": "create calendar event", + "limit": 8, + }), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), ev_function_call_with_namespace( call_id, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_APP_ONLY_TOOL, "{}", ), - ev_completed("resp-1"), + ev_completed("resp-2"), ]), sse(vec![ - ev_response_created("resp-2"), + ev_response_created("resp-3"), ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), + ev_completed("resp-3"), ]), ], ) .await; - let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( "Try to call the app-only calendar tool.", @@ -266,25 +270,25 @@ async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> R let requests = mock.requests(); assert!( - namespace_child_tool( - &requests[0].body_json(), + tool_search_output_has_namespace_child( + &requests[1], + search_call_id, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_CREATE_TOOL - ) - .is_some(), - "visible tool from the app-only tool's connector should be declared" + ), + "visible tool from the app-only tool's connector should be searchable" ); assert!( - namespace_child_tool( - &requests[0].body_json(), + !tool_search_output_has_namespace_child( + &requests[1], + search_call_id, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_APP_ONLY_TOOL - ) - .is_none(), - "app-only tool should not be declared to a direct model" + ), + "app-only tool should not be returned to the model by tool_search" ); assert!( - requests[1] + requests[2] .function_call_output(call_id) .get("output") .and_then(Value::as_str) @@ -423,7 +427,7 @@ async fn search_tool_hides_apps_tools_without_search() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn explicit_app_mentions_respect_always_defer() -> Result<()> { +async fn explicit_app_mentions_leave_app_tools_deferred() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -438,13 +442,7 @@ async fn explicit_app_mentions_respect_always_defer() -> Result<()> { ) .await; - let mut builder = - configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -458,7 +456,7 @@ async fn explicit_app_mentions_respect_always_defer() -> Result<()> { let tools = tool_names(&body); assert!( tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), - "explicit app mentions should leave app tools deferred when always-defer is active: {tools:?}" + "explicit app mentions should leave app tools deferred: {tools:?}" ); assert!( namespace_child_tool( @@ -1214,10 +1212,6 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()).with_config(move |config| { - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); let mut servers = config.mcp_servers.get().clone(); servers.insert( "rmcp".to_string(), diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 2de9bf26cb..7beaa17101 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -31,7 +31,7 @@ async fn write_repo_skill( body: &str, ) -> Result<()> { let skill_dir = cwd.join(".agents").join("skills").join(name); - let skill_dir_uri = PathUri::from_path(&skill_dir)?; + let skill_dir_uri = PathUri::from_host_native_path(&skill_dir)?; fs.create_directory( &skill_dir_uri, CreateDirectoryOptions { recursive: true }, @@ -40,7 +40,7 @@ async fn write_repo_skill( .await?; let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"); let path = skill_dir.join("SKILL.md"); - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; fs.write_file(&path_uri, contents.into_bytes(), /*sandbox*/ None) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index 12bab10ec8..49687d347b 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/token_budget.rs +assertion_line: 545 expression: snapshot --- Scenario: New context window tool installs fresh full context before the next follow-up request. @@ -8,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n + [03] \nThread id: \nFirst context window id: \nCurrent context window id: \nPrevious context window id: \n 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index eae82de9e1..36a51f80c3 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -356,6 +356,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { fs::create_dir_all(parent).expect("should create rollout directory"); let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 2b9395f598..53f9a97bc7 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,7 +17,6 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -28,6 +27,7 @@ use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; @@ -47,6 +47,7 @@ use wiremock::MockServer; const SPAWN_CALL_ID: &str = "spawn-call-1"; const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const TURN_0_FORK_PROMPT: &str = "seed fork context"; const TURN_1_PROMPT: &str = "spawn a child and continue"; const TURN_2_NO_WAIT_PROMPT: &str = "follow up without wait"; @@ -62,6 +63,23 @@ const SUBAGENT_STOP_CONTINUATION: &str = "continue only the child"; const INTERNAL_SUBAGENT_PROMPT: &str = "internal subagent: review"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { + decoded_body(req) + .and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + +fn request_has_input_type(req: &wiremock::Request, ty: &str) -> bool { + decoded_body(req) + .and_then(|body| serde_json::from_slice::(&body).ok()) + .and_then(|body| body.get("input").and_then(Value::as_array).cloned()) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some(ty)) + }) +} + +fn decoded_body(req: &wiremock::Request) -> Option> { let is_zstd = req .headers .get("content-encoding") @@ -71,14 +89,11 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool { .split(',') .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) }); - let bytes = if is_zstd { + if is_zstd { zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok() } else { Some(req.body.clone()) - }; - bytes - .and_then(|body| String::from_utf8(body).ok()) - .is_some_and(|body| body.contains(text)) + } } fn has_subagent_notification(req: &ResponsesRequest) -> bool { @@ -1039,14 +1054,19 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) .await; let child_request_log = mount_sse_once_match( &server, - |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + |req: &wiremock::Request| request_has_input_type(req, "agent_message"), sse(vec![ ev_response_created("resp-child-1"), ev_completed("resp-child-1"), @@ -1056,7 +1076,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result mount_sse_once_match( &server, |req: &wiremock::Request| { - body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + body_contains(req, SPAWN_CALL_ID) && !request_has_input_type(req, "agent_message") }, sse(vec![ ev_response_created("resp-parent-2"), @@ -1085,14 +1105,11 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result .pop() .expect("child request"); assert_eq!( - child_request.inputs_of_type("agent_message"), - vec![json!({ + strip_metadata_from_json(Value::Array(child_request.inputs_of_type("agent_message"))), + Value::Array(vec![json!({ "type": "agent_message", "author": "/root", "recipient": "/root/worker", - "metadata": { - "source_call_id": SPAWN_CALL_ID, - }, "content": [ { "type": "input_text", @@ -1103,7 +1120,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result "encrypted_content": encrypted_message, }, ], - })] + })]) ); Ok(()) @@ -1131,7 +1148,12 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) @@ -1146,7 +1168,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( }; let child_request = mount_response_once_match( &server, - |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + |req: &wiremock::Request| request_has_input_type(req, "agent_message"), sse_response(sse(child_events)).set_delay(Duration::from_secs(1)), ) .await; @@ -1185,7 +1207,12 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( }, sse(vec![ ev_response_created("resp-parent-3"), - ev_function_call("wait-agent-call", "wait_agent", "{}"), + ev_function_call_with_namespace( + "wait-agent-call", + MULTI_AGENT_V2_NAMESPACE, + "wait_agent", + "{}", + ), ev_completed("resp-parent-3"), ]), ) @@ -1231,8 +1258,8 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( .pop() .expect("agent message request"); assert_eq!( - request.inputs_of_type("agent_message"), - vec![json!({ + strip_metadata_from_json(Value::Array(request.inputs_of_type("agent_message"))), + Value::Array(vec![json!({ "type": "agent_message", "author": "/root/worker", "recipient": "/root", @@ -1240,7 +1267,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( "type": "input_text", "text": notification, }], - })] + })]) ); Ok(()) diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index db70d3f33c..09399e511e 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1,6 +1,11 @@ use anyhow::Result; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; +use codex_core::config::TokenBudgetConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; +use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use core_test_support::PathBufExt; @@ -17,24 +22,52 @@ use core_test_support::responses::mount_sse_sequence; 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::local; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; +use std::time::Duration; const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000; -const EFFECTIVE_CONTEXT_WINDOW: i64 = CONFIGURED_CONTEXT_WINDOW * 95 / 100; -fn token_budget_texts(request: &ResponsesRequest) -> Vec { +fn token_budget_contexts(request: &ResponsesRequest) -> Vec { + let context_window_prefix = format!("{CONTEXT_WINDOW_OPEN_TAG}\nThread id: "); request .message_input_texts("developer") .into_iter() - .filter(|text| text.starts_with("")) + .filter(|text| text.starts_with(&context_window_prefix)) .collect() } +fn token_budget_window_ids( + text: &str, + thread_id: codex_protocol::ThreadId, +) -> (String, Option, String) { + let captures = assert_regex_match( + &format!( + r"^{CONTEXT_WINDOW_OPEN_TAG}\nThread id: {thread_id}\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})(?:\nPrevious context window id: ([0-9a-f-]{{36}}))?\n{CONTEXT_WINDOW_CLOSE_TAG}$" + ), + text, + ); + let first_window_id = captures + .get(1) + .expect("first window id capture") + .as_str() + .to_string(); + let window_id = captures + .get(2) + .expect("window id capture") + .as_str() + .to_string(); + let previous_window_id = captures.get(3).map(|capture| capture.as_str().to_string()); + (first_window_id, previous_window_id, window_id) +} + fn tool_names(request: &ResponsesRequest) -> Vec { request .body_json() @@ -81,16 +114,14 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> assert_eq!(requests.len(), 2); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), - &initial_token_budget[0], - ); + let (first_window_id, previous_window_id, window_id) = + token_budget_window_ids(&initial_token_budget[0], thread_id); + assert_eq!(previous_window_id, None); + assert_eq!(first_window_id, window_id); assert_eq!( - token_budget_texts(&requests[1]), + token_budget_contexts(&requests[1]), initial_token_budget, "steady-state context update should not advance the context window" ); @@ -99,7 +130,90 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> Result<()> { +async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let rmcp_test_server_bin = stdio_server_bin()?; + let test = test_codex() + .with_config(move |config| { + config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + let mut servers = config.mcp_servers.get().clone(); + servers.insert( + "notes".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + }) + .build(&server) + .await?; + wait_for_mcp_server(&test.codex, "notes").await?; + let responses = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ])], + ) + .await; + + test.submit_turn("inject the history hint").await?; + + let request = responses.single_request(); + let thread_id = test.session_configured.thread_id; + let token_budgets = token_budget_contexts(&request); + assert_eq!(token_budgets.len(), 1); + let captures = assert_regex_match( + &format!( + r"^{CONTEXT_WINDOW_OPEN_TAG}\nThread id: {thread_id}\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n{CONTEXT_WINDOW_CLOSE_TAG}$" + ), + &token_budgets[0], + ); + assert_eq!( + captures.get(1).expect("first window id capture").as_str(), + captures.get(2).expect("current window id capture").as_str() + ); + assert!( + !tool_names(&request) + .iter() + .any(|name| name == "mcp__notes__thread_hint"), + "thread_hint should be hidden from model tool exposure" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -108,27 +222,19 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R vec![ sse(vec![ ev_response_created("resp-1"), - ev_completed_with_tokens("resp-1", /*total_tokens*/ 2_500), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 8_000), ]), - sse(vec![ - ev_response_created("resp-2"), - ev_completed_with_tokens("resp-2", /*total_tokens*/ 3_000), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_completed_with_tokens("resp-3", /*total_tokens*/ 5_000), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_completed_with_tokens("resp-4", /*total_tokens*/ 8_000), - ]), - sse(vec![ev_response_created("resp-5"), ev_completed("resp-5")]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), ], ) .await; let test = test_codex() .with_config(|config| { config.model_context_window = Some(10_000); + config.token_budget = Some(TokenBudgetConfig { + reminder_threshold_tokens: Some(2_000), + ..TokenBudgetConfig::default() + }); config .features .enable(Feature::TokenBudget) @@ -137,54 +243,23 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R .build(&server) .await?; - for turn in 1..=5 { - test.submit_turn(&format!("turn {turn}")).await?; - } + test.submit_turn("cross threshold").await?; + test.submit_turn("observe reminder").await?; let requests = responses.requests(); - assert_eq!(requests.len(), 5); - - let thread_id = test.session_configured.thread_id; - let full_context = token_budget_texts(&requests[0]); - assert_eq!(full_context.len(), 1); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" - ), - &full_context[0], - ); - let full_context = full_context[0].clone(); - let threshold_25 = - "\nYou have 7000 tokens left in this context window.\n" - .to_string(); - let threshold_50 = - "\nYou have 4500 tokens left in this context window.\n" - .to_string(); - let threshold_75 = - "\nYou have 1500 tokens left in this context window.\n" - .to_string(); - - assert_eq!(token_budget_texts(&requests[0]), vec![full_context.clone()]); + assert_eq!(requests.len(), 2); + let initial_context = token_budget_contexts(&requests[0]); + assert_eq!(initial_context.len(), 1); + let reminder = "Your context window is nearly exhausted (only 1000 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows."; assert_eq!( - token_budget_texts(&requests[1]), - vec![full_context.clone(), threshold_25.clone()] - ); - assert_eq!( - token_budget_texts(&requests[2]), - vec![full_context.clone(), threshold_25.clone()] - ); - assert_eq!( - token_budget_texts(&requests[3]), - vec![ - full_context.clone(), - threshold_25.clone(), - threshold_50.clone() - ] - ); - assert_eq!( - token_budget_texts(&requests[4]), - vec![full_context, threshold_25, threshold_50, threshold_75] + requests[1] + .message_input_texts("developer") + .into_iter() + .filter(|text| text == reminder) + .count(), + 1 ); + assert_eq!(token_budget_contexts(&requests[1]), initial_context); Ok(()) } @@ -240,18 +315,10 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu ); let thread_id = test.session_configured.thread_id; - let remaining_context = - "\nYou have 7000 tokens left in this context window.\n" - .to_string(); - let token_budgets = token_budget_texts(&requests[1]); - assert_eq!(token_budgets.len(), 2); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" - ), - &token_budgets[0], - ); - assert_eq!(token_budgets[1], remaining_context); + let remaining_context = "You have 7000 tokens left in this context window.".to_string(); + let token_budgets = token_budget_contexts(&requests[1]); + assert_eq!(token_budgets.len(), 1); + token_budget_window_ids(&token_budgets[0], thread_id); assert_eq!( requests[2].function_call_output_content_and_success(call_id), Some((Some(remaining_context), None)) @@ -308,14 +375,11 @@ async fn get_context_remaining_returns_unknown_when_window_is_unavailable() -> R "get_context_remaining should be exposed when token budget is enabled" ); - assert_eq!(token_budget_texts(&requests[0]), Vec::::new()); + assert_eq!(token_budget_contexts(&requests[0]), Vec::::new()); assert_eq!( requests[1].function_call_output_content_and_success(call_id), Some(( - Some( - "\nYou have unknown tokens left in this context window.\n" - .to_string() - ), + Some("You have unknown tokens left in this context window.".to_string()), None, )) ); @@ -371,30 +435,24 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { assert_eq!(requests.len(), 3); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let initial_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), - &initial_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); - let post_compaction_token_budget = token_budget_texts(&requests[2]); + let (initial_first_window_id, initial_previous_window_id, initial_window_id) = + token_budget_window_ids(&initial_token_budget[0], thread_id); + let post_compaction_token_budget = token_budget_contexts(&requests[2]); assert_eq!(post_compaction_token_budget.len(), 1); - let post_compaction_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), - &post_compaction_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); + let ( + post_compaction_first_window_id, + post_compaction_previous_window_id, + post_compaction_window_id, + ) = token_budget_window_ids(&post_compaction_token_budget[0], thread_id); + assert_eq!(initial_previous_window_id, None); + assert_eq!(initial_first_window_id, initial_window_id); + assert_eq!(post_compaction_first_window_id, initial_first_window_id); + assert_eq!( + post_compaction_previous_window_id.as_deref(), + Some(initial_window_id.as_str()) + ); assert_ne!(post_compaction_window_id, initial_window_id); Ok(()) @@ -456,31 +514,20 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { "new_context should be exposed when token budget is enabled" ); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let initial_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), - &initial_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); - let new_window_token_budget = token_budget_texts(&requests[2]); + let (initial_first_window_id, _, initial_window_id) = + token_budget_window_ids(&initial_token_budget[0], thread_id); + let new_window_token_budget = token_budget_contexts(&requests[2]); assert_eq!(new_window_token_budget.len(), 1); - let window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), - &new_window_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); - assert_ne!(window_id, initial_window_id); + let (new_first_window_id, new_previous_window_id, new_window_id) = + token_budget_window_ids(&new_window_token_budget[0], thread_id); + assert_eq!(new_first_window_id, initial_first_window_id); + assert_eq!( + new_previous_window_id.as_deref(), + Some(initial_window_id.as_str()) + ); + assert_ne!(new_window_id, initial_window_id); assert!( !requests[2].body_contains_text("request new context window"), "new_context should drop the prior window history before continuing the turn" @@ -496,7 +543,8 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { ); let snapshot = snapshot .replace(&thread_id.to_string(), "") - .replace(&window_id, ""); + .replace(&new_first_window_id, "") + .replace(&new_window_id, ""); insta::assert_snapshot!( "token_budget_new_context_window_tool_full_context", snapshot @@ -504,3 +552,51 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_hides_new_context_tool() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ])], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + config + .features + .disable(Feature::AutoCompaction) + .expect("test config should allow disabling auto-compaction"); + }) + .build(&server) + .await?; + + test.submit_turn("preserve the current context window") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 1); + let tool_names = tool_names(&requests[0]); + assert!( + tool_names + .iter() + .any(|name| name == "get_context_remaining"), + "token budget should continue to expose get_context_remaining" + ); + assert!( + !tool_names.iter().any(|name| name == "new_context"), + "disabled auto-compaction should hide new_context" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index cc942cbc17..c7e296ec0c 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -474,7 +474,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { let rmcp_test_server_bin = stdio_server_bin()?; // 1x1 PNG data URL - let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ee9bQAAAABJRU5ErkJggg=="; + let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let mut builder = test_codex().with_config(move |config| { let mut servers = config.mcp_servers.get().clone(); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 8187fc0cb1..fe6ce7fbb7 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -229,7 +229,7 @@ async fn create_workspace_directory( rel_path: impl AsRef, ) -> Result { let abs_path = test.config.cwd.join(rel_path.as_ref()); - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; test.fs() .create_directory( &abs_path_uri, @@ -437,7 +437,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec"); - assert_eq!(begin_event.cwd, PathUri::from_path(&cwd)?); + assert_eq!(begin_event.cwd, PathUri::from_host_native_path(&cwd)?); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -508,7 +508,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { assert_eq!( begin_event.cwd, - PathUri::from_path(&workdir)?, + PathUri::from_host_native_path(&workdir)?, "exec_command cwd should resolve relative workdir against turn cwd", ); @@ -570,7 +570,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { assert_eq!( begin_event.cwd, - PathUri::from_path(&workdir)?, + PathUri::from_host_native_path(&workdir)?, "exec_command cwd should reflect the requested workdir override" ); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 1857eedefd..a660ec4da6 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -7,7 +7,6 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; -use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; @@ -134,7 +133,7 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result { let abs_path = test.config.cwd.join(rel_path); - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; test.fs() .create_directory( &abs_path_uri, @@ -152,7 +151,7 @@ async fn write_workspace_file( ) -> anyhow::Result { let abs_path = test.config.cwd.join(rel_path); if let Some(parent) = abs_path.parent() { - let parent_uri = PathUri::from_path(&parent)?; + let parent_uri = PathUri::from_host_native_path(&parent)?; test.fs() .create_directory( &parent_uri, @@ -161,7 +160,7 @@ async fn write_workspace_file( ) .await?; } - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; test.fs() .write_file(&abs_path_uri, contents, /*sandbox*/ None) .await?; @@ -181,15 +180,10 @@ async fn write_workspace_png( async fn assert_user_turn_local_image_resizes_to( original_dimensions: (u32, u32), expected_dimensions: (u32, u32), - resize_policy: TestImageResizePolicy, ) -> anyhow::Result<()> { let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - if resize_policy == TestImageResizePolicy::AllImages { - let _ = config.features.enable(Feature::ResizeAllImages); - } - }); + let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { codex, @@ -263,42 +257,25 @@ async fn assert_user_turn_local_image_resizes_to( Ok(()) } -#[derive(Clone, Copy, Eq, PartialEq)] -enum TestImageResizePolicy { - Legacy, - AllImages, -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768), TestImageResizePolicy::Legacy) - .await + assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_vertical_local_image_resizes_to_square_bounds() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (1024, 4096), - (512, 2048), - TestImageResizePolicy::Legacy, - ) - .await + assert_user_turn_local_image_resizes_to((1024, 4096), (512, 2048)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_applies_patch_budget_to_local_user_image() -> anyhow::Result<()> { +async fn user_turn_local_image_applies_patch_budget() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (2048, 2048), - (1600, 1600), - TestImageResizePolicy::AllImages, - ) - .await + assert_user_turn_local_image_resizes_to((2048, 2048), (1600, 1600)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -612,7 +589,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() )) .abs(); let image_path = remote_cwd.join("remote.png"); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -621,7 +598,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() ) .await?; let png = png_bytes(/*width*/ 1, /*height*/ 1, [0, 255, 0, 255])?; - let image_path_uri = PathUri::from_path(&image_path)?; + let image_path_uri = PathUri::from_host_native_path(&image_path)?; test.fs() .write_file(&image_path_uri, png, /*sandbox*/ None) .await?; @@ -1209,11 +1186,10 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { +async fn view_image_tool_turns_invalid_image_into_placeholder() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { @@ -1222,84 +1198,6 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { .. } = &test; - let rel_path = "assets/example.json"; - let abs_path = - write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; - - let call_id = "view-image-non-image"; - let arguments = serde_json::json!({ "path": rel_path }).to_string(); - - let first_response = sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "view_image", &arguments), - ev_completed("resp-1"), - ]); - responses::mount_sse_once(&server, first_response).await; - - let second_response = sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]); - let mock = responses::mount_sse_once(&server, second_response).await; - - let session_model = session_configured.model.clone(); - - codex - .submit(disabled_user_turn( - &test, - vec![UserInput::Text { - text: "please use the view_image tool to read the json file".into(), - text_elements: Vec::new(), - }], - session_model, - )) - .await?; - - wait_for_event_with_timeout( - codex, - |event| matches!(event, EventMsg::TurnComplete(_)), - VIEW_IMAGE_TURN_COMPLETE_TIMEOUT, - ) - .await; - - let request = mock.single_request(); - assert!( - request.inputs_of_type("input_image").is_empty(), - "non-image file should not produce an input_image message" - ); - let (error_text, success) = request - .function_call_output_content_and_success(call_id) - .expect("function_call_output should be present"); - assert_eq!(success, None); - let error_text = error_text.expect("error text present"); - - let expected_error = format!( - "unable to process image at `{}`: unsupported image `application/json`", - abs_path.display() - ); - assert!( - error_text.contains(&expected_error), - "error should describe unsupported file type: {error_text}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_turns_invalid_view_image_into_placeholder() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }); - let test = builder.build_with_remote_env(&server).await?; - let TestCodex { - codex, - session_configured, - .. - } = &test; - let rel_path = "assets/invalid-image.json"; write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; let call_id = "view-image-invalid-placeholder"; diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 88c0c80342..25d1b54e8b 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -926,6 +926,7 @@ impl SessionState { exit_code: None, closed: true, failure: Some(message), + sandbox_denied: false, } } @@ -1869,6 +1870,7 @@ mod tests { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) .expect("read response should serialize"), }), diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index 77feb0a4ba..f58e365728 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -58,6 +58,7 @@ impl SessionState { exit_code, closed, failure, + sandbox_denied: _, } = response; if let Some(message) = failure { return Err(ExecServerError::Protocol(format!( diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 9496bb8be6..40d73cade5 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -488,7 +488,9 @@ impl Environment { exec_server_url: None, remote_client: None, startup_task: Arc::new(Mutex::new(None)), - exec_backend: Arc::new(LocalProcess::default()), + exec_backend: Arc::new(LocalProcess::with_local_runtime_paths( + local_runtime_paths.clone(), + )), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), )), @@ -1149,13 +1151,17 @@ mod tests { .start(crate::ExecParams { process_id: ProcessId::from("default-env-proc"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir().expect("read current dir")) - .expect("cwd URI"), + cwd: PathUri::from_host_native_path( + std::env::current_dir().expect("read current dir"), + ) + .expect("cwd URI"), env_policy: None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .expect("start process"); @@ -1163,6 +1169,52 @@ mod tests { assert_eq!(response.process.process_id().as_str(), "default-env-proc"); } + #[tokio::test] + async fn local_environment_passes_runtime_paths_to_exec_backend() { + let environment = Environment::local(test_runtime_paths()); + #[cfg(unix)] + let uri = "file://server/share/checkout"; + #[cfg(windows)] + let uri = "file:///usr/local/checkout"; + let sandbox_cwd = PathUri::parse(uri).expect("non-native sandbox cwd URI"); + let source = sandbox_cwd + .to_abs_path() + .expect_err("sandbox cwd should not be native to this host"); + let sandbox = crate::FileSystemSandboxContext::from_permission_profile_with_cwd( + codex_protocol::models::PermissionProfile::workspace_write(), + sandbox_cwd.clone(), + ); + + let result = environment + .get_exec_backend() + .start(crate::ExecParams { + process_id: ProcessId::from("local-sandbox-proc"), + argv: vec!["true".to_string()], + cwd: PathUri::from_host_native_path( + std::env::current_dir().expect("read current dir"), + ) + .expect("cwd URI"), + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }) + .await; + let Err(err) = result else { + panic!("sandbox cwd should be rejected after resolving runtime paths"); + }; + + assert_eq!( + err.to_string(), + format!( + "exec-server rejected request (-32602): sandbox cwd URI `{sandbox_cwd}` is not valid on this exec-server host: {source}" + ) + ); + } + #[tokio::test] async fn test_environment_rejects_sandboxed_filesystem_without_runtime_paths() { let environment = Environment::default_for_tests(); diff --git a/codex-rs/exec-server/src/fs_helper.rs b/codex-rs/exec-server/src/fs_helper.rs index f4db844b5b..9fa74bf3d1 100644 --- a/codex-rs/exec-server/src/fs_helper.rs +++ b/codex-rs/exec-server/src/fs_helper.rs @@ -314,8 +314,9 @@ mod tests { #[test] fn helper_protocol_uses_path_uris() -> serde_json::Result<()> { - let local_path = PathUri::from_path(std::env::current_dir().expect("cwd").join("file")) - .expect("path URI"); + let local_path = + PathUri::from_host_native_path(std::env::current_dir().expect("cwd").join("file")) + .expect("path URI"); let paths = [ local_path, PathUri::parse("file://server/share/file").expect("path URI"), diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index c96c443447..d5c71674ac 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -16,6 +16,7 @@ mod noise_channel; mod noise_relay; mod process; mod process_id; +mod process_sandbox; mod protocol; mod regular_file; mod relay; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 5e1b52b11c..1f0dc5fec9 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -10,7 +10,11 @@ use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; +use codex_sandboxing::SandboxType; +use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -26,9 +30,11 @@ use crate::ExecProcessEvent; use crate::ExecProcessEventReceiver; use crate::ExecProcessFuture; use crate::ExecServerError; +use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::StartedExecProcess; use crate::process::ExecProcessEventLog; +use crate::process_sandbox::prepare_exec_request; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecEnvPolicy; @@ -85,6 +91,8 @@ struct RunningProcess { output_notify: Arc, open_streams: usize, closed: bool, + sandbox: SandboxType, + sandbox_denied: bool, } /// Bounded cache of stdin write ids that have already been accepted for one process. @@ -133,6 +141,7 @@ struct Inner { #[derive(Clone)] pub(crate) struct LocalProcess { inner: Arc, + runtime_paths: Option, } struct LocalExecProcess { @@ -144,20 +153,39 @@ struct LocalExecProcess { impl Default for LocalProcess { fn default() -> Self { - let (outgoing_tx, mut outgoing_rx) = - mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); - tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::new(RpcNotificationSender::new(outgoing_tx)) + Self::with_discarded_notifications(/*runtime_paths*/ None) } } impl LocalProcess { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn with_local_runtime_paths(runtime_paths: ExecServerRuntimePaths) -> Self { + Self::with_discarded_notifications(Some(runtime_paths)) + } + + fn with_discarded_notifications(runtime_paths: Option) -> Self { + let (outgoing_tx, mut outgoing_rx) = + mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); + tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); + Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), runtime_paths) + } + + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { + Self::with_runtime_paths(notifications, Some(runtime_paths)) + } + + fn with_runtime_paths( + notifications: RpcNotificationSender, + runtime_paths: Option, + ) -> Self { Self { inner: Arc::new(Inner { notifications: std::sync::RwLock::new(Some(notifications)), processes: Mutex::new(HashMap::new()), }), + runtime_paths, } } @@ -191,16 +219,12 @@ impl LocalProcess { params: ExecParams, ) -> Result<(ExecResponse, watch::Sender, ExecProcessEventLog), JSONRPCErrorError> { let process_id = params.process_id.clone(); - let (program, args) = params - .argv + let prepared = + prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?; + let (program, args) = prepared + .command .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; - let native_cwd = params.cwd.to_abs_path().map_err(|err| { - invalid_params(format!( - "cwd URI `{}` is not valid on this exec-server host: {err}", - params.cwd - )) - })?; let start = Arc::new(ProcessStart); { @@ -216,14 +240,13 @@ impl LocalProcess { ); } - let env = child_env(¶ms); let spawned_result = if params.tty { codex_utils_pty::spawn_pty_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, TerminalSize::default(), ) .await @@ -231,18 +254,18 @@ impl LocalProcess { codex_utils_pty::spawn_pipe_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await } else { codex_utils_pty::spawn_pipe_process_no_stdin( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await }; @@ -296,6 +319,8 @@ impl LocalProcess { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: prepared.sandbox, + sandbox_denied: false, })), ); } @@ -390,6 +415,7 @@ impl LocalProcess { exit_code: process.exit_code, closed: process.closed, failure: None, + sandbox_denied: process.sandbox_denied, }, Arc::clone(&process.output_notify), ) @@ -780,12 +806,46 @@ async fn watch_exit( output_notify: Arc, ) { let exit_code = exit_rx.await.unwrap_or(-1); + let sandboxed = { + let processes = inner.processes.lock().await; + matches!( + processes.get(&process_id), + Some(ProcessEntry::Running(process)) if process.sandbox != SandboxType::None + ) + }; + if sandboxed { + let _ = tokio::time::timeout(Duration::from_millis(20), output_notify.notified()).await; + } let notification = { let mut processes = inner.processes.lock().await; if let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) { let seq = process.next_seq; process.next_seq += 1; process.exit_code = Some(exit_code); + if process.sandbox != SandboxType::None { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut aggregated = Vec::new(); + for chunk in &process.output { + match chunk.stream { + ExecOutputStream::Stdout | ExecOutputStream::Pty => { + stdout.extend_from_slice(&chunk.chunk); + } + ExecOutputStream::Stderr => stderr.extend_from_slice(&chunk.chunk), + } + aggregated.extend_from_slice(&chunk.chunk); + } + let exec_output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(String::from_utf8_lossy(&stdout).into_owned()), + stderr: StreamOutput::new(String::from_utf8_lossy(&stderr).into_owned()), + aggregated_output: StreamOutput::new( + String::from_utf8_lossy(&aggregated).into_owned(), + ), + ..Default::default() + }; + process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output); + } let _ = process.wake_tx.send(seq); process .events @@ -896,12 +956,15 @@ mod tests { ExecParams { process_id: ProcessId::from("env-test"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"), + cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd")) + .expect("cwd URI"), env_policy: None, env, tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } @@ -981,6 +1044,7 @@ mod tests { exit_code: Some(0), closed: false, failure: None, + sandbox_denied: false, } ); @@ -1105,6 +1169,8 @@ mod tests { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: SandboxType::None, + sandbox_denied: false, })), ); assert!(previous.is_none()); diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs new file mode 100644 index 0000000000..d157f4c6dc --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -0,0 +1,135 @@ +use std::collections::HashMap; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_protocol::models::PermissionProfile; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxDirectSpawnTransformRequest; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; + +use crate::ExecServerRuntimePaths; +use crate::protocol::ExecParams; +use crate::rpc::invalid_params; + +pub(crate) struct PreparedExecRequest { + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) env: HashMap, + pub(crate) arg0: Option, + pub(crate) sandbox: SandboxType, +} + +pub(crate) fn prepare_exec_request( + params: &ExecParams, + env: HashMap, + runtime_paths: Option<&ExecServerRuntimePaths>, +) -> Result { + let Some(sandbox_context) = params.sandbox.as_ref() else { + return Ok(PreparedExecRequest { + command: params.argv.clone(), + cwd: native_path(¶ms.cwd, "cwd")?, + env, + arg0: params.arg0.clone(), + sandbox: SandboxType::None, + }); + }; + let runtime_paths = runtime_paths + .ok_or_else(|| invalid_params("sandbox runtime paths are not configured".to_string()))?; + // TODO(jif): Transport permissions before orchestrator-local paths are materialized, + // then resolve executor-local helper and workspace paths here. + let permissions: PermissionProfile = sandbox_context + .permissions + .clone() + .try_into() + .map_err(|err| invalid_params(format!("invalid sandbox permission path URI: {err}")))?; + let sandbox_policy_cwd = sandbox_context.cwd.as_ref().unwrap_or(¶ms.cwd); + let native_sandbox_policy_cwd = native_path(sandbox_policy_cwd, "sandbox cwd")?; + let native_workspace_roots = sandbox_context + .workspace_roots + .iter() + .map(|root| native_path(root, "sandbox workspace root")) + .collect::, _>>()?; + let workspace_roots = if native_workspace_roots.is_empty() { + std::slice::from_ref(&native_sandbox_policy_cwd) + } else { + native_workspace_roots.as_slice() + }; + let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots); + let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); + let sandbox_manager = SandboxManager::new(); + let sandbox = sandbox_manager.select_initial( + &file_system_policy, + network_policy, + SandboxablePreference::Require, + sandbox_context.windows_sandbox_level, + params.enforce_managed_network, + ); + match sandbox { + SandboxType::None => { + return Err(invalid_params( + "sandbox intent cannot be enforced on this executor".to_string(), + )); + } + SandboxType::WindowsRestrictedToken => { + // TODO(jif): Launch generic remote commands through the Windows sandbox session API + // while preserving argv and TTY behavior and passing the child environment out of band. + return Err(invalid_params( + "sandboxed remote process launch is not supported on Windows".to_string(), + )); + } + SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp => {} + } + let (program, args) = params + .argv + .split_first() + .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + let request = sandbox_manager + .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { + workspace_roots, + transform: SandboxTransformRequest { + // TODO(jif): Preserve params.arg0 for the inner command across the sandbox + // wrapper, or reject sandboxed requests with a custom arg0. + command: SandboxCommand { + program: program.into(), + args: args.to_vec(), + cwd: params.cwd.clone(), + env, + additional_permissions: None, + }, + permissions: &permissions, + sandbox, + enforce_managed_network: params.enforce_managed_network, + environment_id: None, + network: None, + sandbox_policy_cwd, + codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: sandbox_context.use_legacy_landlock, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + }, + }) + .map_err(|err| invalid_params(format!("failed to prepare process sandbox: {err}")))?; + Ok(PreparedExecRequest { + command: request.command, + cwd: native_path(&request.cwd, "cwd")?, + env: request.env, + arg0: request.arg0, + sandbox: request.sandbox, + }) +} + +fn native_path(path: &PathUri, label: &str) -> Result { + path.to_abs_path().map_err(|err| { + invalid_params(format!( + "{label} URI `{path}` is not valid on this exec-server host: {err}" + )) + }) +} + +#[cfg(test)] +#[path = "process_sandbox_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs new file mode 100644 index 0000000000..1b0408afd8 --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +#[cfg(unix)] +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; + +use super::prepare_exec_request; +use crate::ExecParams; +#[cfg(unix)] +use crate::ExecServerRuntimePaths; +#[cfg(unix)] +use crate::FileSystemSandboxContext; +use crate::ProcessId; + +#[cfg(unix)] +#[test] +fn sandbox_request_wraps_native_argv_on_executor() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = + ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::workspace_write(), + cwd_uri.clone(), + ); + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "pwd".to_string(), + ], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .expect("prepare sandboxed request"); + + assert_ne!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + #[cfg(target_os = "linux")] + { + assert_eq!( + prepared.command.first(), + Some(&runtime_paths.codex_self_exe.to_string_lossy().into_owned()) + ); + let permission_profile_json = prepared + .command + .iter() + .position(|arg| arg == "--permission-profile") + .and_then(|index| prepared.command.get(index + 1)) + .expect("sandbox wrapper permission profile"); + let permission_profile: PermissionProfile = + serde_json::from_str(permission_profile_json).expect("permission profile JSON"); + assert_eq!( + permission_profile, + PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)) + ); + } + #[cfg(target_os = "macos")] + assert_eq!( + prepared.command.first().map(String::as_str), + Some("/usr/bin/sandbox-exec") + ); +} + +#[test] +fn native_request_preserves_native_launch_fields() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let env = HashMap::from([("TEST_ENV".to_string(), "value".to_string())]); + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: Some("custom-arg0".to_string()), + sandbox: None, + enforce_managed_network: false, + }; + + let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) + .expect("prepare native request"); + + assert_eq!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + assert_eq!(prepared.env, env); + assert_eq!(prepared.arg0, params.arg0); +} diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 97ef86aad2..8771e869df 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -103,6 +103,12 @@ pub struct ExecParams { /// Optional process-visible argv0 override. Values such as `codex-linux-sandbox` are command /// names rather than paths, so this is not a [`PathUri`]. pub arg0: Option, + /// Portable sandbox intent. Concrete wrapper argv is resolved by the exec-server. + #[serde(default)] + pub sandbox: Option, + /// Whether the eventual executor-side sandbox must enforce managed networking. + #[serde(default)] + pub enforce_managed_network: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -147,6 +153,9 @@ pub struct ReadResponse { pub exit_code: Option, pub closed: bool, pub failure: Option, + /// Whether the executor classified the process failure as a sandbox denial. + #[serde(default)] + pub sandbox_denied: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -510,7 +519,7 @@ mod tests { let legacy_cwd = std::env::current_dir().expect("current directory"); let native_sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( PermissionProfile::default(), - PathUri::from_path(&legacy_cwd).expect("cwd URI"), + PathUri::from_host_native_path(&legacy_cwd).expect("cwd URI"), ); let mut legacy_sandbox = serde_json::to_value(&native_sandbox).expect("sandbox should serialize"); @@ -522,7 +531,7 @@ mod tests { .expect("legacy absolute path should deserialize"); let expected_sandbox = native_sandbox; let expected = FsReadFileParams { - path: PathUri::from_path(legacy_path).expect("path URI"), + path: PathUri::from_host_native_path(legacy_path).expect("path URI"), sandbox: Some(expected_sandbox.clone()), }; diff --git a/codex-rs/exec-server/src/server/file_system_handler.rs b/codex-rs/exec-server/src/server/file_system_handler.rs index ddf17f21cd..1a6e073936 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -274,7 +274,7 @@ mod tests { ) .expect("runtime paths"); let handler = FileSystemHandler::new(runtime_paths); - let sandbox_cwd = PathUri::from_path(temp_dir.path()).expect("tempdir URI"); + let sandbox_cwd = PathUri::from_host_native_path(temp_dir.path()).expect("tempdir URI"); let sandbox_context = |sandbox_policy| { FileSystemSandboxContext::from_legacy_sandbox_policy( sandbox_policy, @@ -292,7 +292,8 @@ mod tests { }, ), ] { - let path = PathUri::from_path(temp_dir.path().join(file_name)).expect("path URI"); + let path = + PathUri::from_host_native_path(temp_dir.path().join(file_name)).expect("path URI"); handler .write_file(FsWriteFileParams { @@ -312,7 +313,7 @@ mod tests { .expect("canonicalize file"); assert_eq!( canonicalized.path, - PathUri::from_path( + PathUri::from_host_native_path( std::fs::canonicalize(temp_dir.path().join(file_name)).expect("canonical path"), ) .expect("canonical path URI"), diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index e0ede94c77..07575a1c42 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -66,6 +66,7 @@ pub(crate) struct ExecServerHandler { background_task_shutdown: CancellationToken, background_tasks: TaskTracker, file_system: FileSystemHandler, + runtime_paths: ExecServerRuntimePaths, initialize_requested: AtomicBool, initialized: AtomicBool, } @@ -83,7 +84,8 @@ impl ExecServerHandler { active_body_stream_ids: Mutex::new(HashSet::new()), background_task_shutdown: CancellationToken::new(), background_tasks: TaskTracker::new(), - file_system: FileSystemHandler::new(runtime_paths), + file_system: FileSystemHandler::new(runtime_paths.clone()), + runtime_paths, initialize_requested: AtomicBool::new(false), initialized: AtomicBool::new(false), } @@ -116,7 +118,11 @@ impl ExecServerHandler { let session = match self .session_registry - .attach(params.resume_session_id.clone(), self.notifications.clone()) + .attach( + params.resume_session_id.clone(), + self.notifications.clone(), + self.runtime_paths.clone(), + ) .await { Ok(session) => session, diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 0bd682bd96..ebb2b6e6ea 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -27,12 +27,15 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { ExecParams { process_id: ProcessId::from(process_id), argv, - cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"), + cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd")) + .expect("cwd URI"), env_policy: None, env: inherited_path_env(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/server/process_handler.rs b/codex-rs/exec-server/src/server/process_handler.rs index 9fced9c166..f628b242b0 100644 --- a/codex-rs/exec-server/src/server/process_handler.rs +++ b/codex-rs/exec-server/src/server/process_handler.rs @@ -1,5 +1,6 @@ use codex_app_server_protocol::JSONRPCErrorError; +use crate::ExecServerRuntimePaths; use crate::local_process::LocalProcess; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; @@ -19,9 +20,12 @@ pub(crate) struct ProcessHandler { } impl ProcessHandler { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { Self { - process: LocalProcess::new(notifications), + process: LocalProcess::new(notifications, runtime_paths), } } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 952f414213..e9316e0d54 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -196,6 +196,7 @@ mod tests { use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_utils_path_uri::PathUri; + use pretty_assertions::assert_eq; use serde::Serialize; use serde::de::DeserializeOwned; use tokio::io::AsyncBufReadExt; @@ -211,9 +212,11 @@ mod tests { use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::connection::JsonRpcConnection; + use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; + use crate::protocol::EnvironmentInfo; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::INITIALIZE_METHOD; @@ -225,6 +228,38 @@ mod tests { use crate::protocol::TerminateResponse; use crate::server::session_registry::SessionRegistry; + #[tokio::test] + async fn connection_accepts_pipelined_scalar_requests() { + let registry = SessionRegistry::new(); + let (mut writer, mut lines, task) = spawn_test_connection(registry, "pipelined-scalar"); + + send_request( + &mut writer, + /*id*/ 1, + INITIALIZE_METHOD, + &InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + }, + ) + .await; + let _: InitializeResponse = read_response(&mut lines, /*expected_id*/ 1).await; + send_notification(&mut writer, INITIALIZED_METHOD, &()).await; + + send_request(&mut writer, /*id*/ 2, ENVIRONMENT_INFO_METHOD, &()).await; + send_request(&mut writer, /*id*/ 3, ENVIRONMENT_INFO_METHOD, &()).await; + + let _: EnvironmentInfo = read_response(&mut lines, /*expected_id*/ 2).await; + let _: EnvironmentInfo = read_response(&mut lines, /*expected_id*/ 3).await; + + drop(writer); + drop(lines); + timeout(Duration::from_secs(1), task) + .await + .expect("processor should exit") + .expect("processor should join"); + } + #[tokio::test] async fn transport_disconnect_detaches_session_during_in_flight_read() { let registry = SessionRegistry::new(); @@ -397,12 +432,15 @@ mod tests { ExecParams { process_id, argv: sleep_then_print_argv(), - cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"), + cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd")) + .expect("cwd URI"), env_policy: None, env, tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 59da3b50a7..73b14ff7cd 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use tokio::sync::Mutex; use uuid::Uuid; +use crate::ExecServerRuntimePaths; use crate::rpc::RpcNotificationSender; use crate::rpc::invalid_request; use crate::rpc::session_already_attached; @@ -60,6 +61,7 @@ impl SessionRegistry { self: &Arc, resume_session_id: Option, notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, ) -> Result { enum AttachOutcome { Attached(Arc), @@ -95,7 +97,7 @@ impl SessionRegistry { let session_id = Uuid::new_v4().to_string(); let entry = Arc::new(SessionEntry::new( session_id.clone(), - ProcessHandler::new(notifications), + ProcessHandler::new(notifications, runtime_paths), connection_id, )); sessions.insert(session_id, Arc::clone(&entry)); diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 11a3f3ece7..2f9b9c1dbe 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -75,12 +75,14 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { .start(ExecParams { process_id: ProcessId::from("proc-1"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -216,17 +218,19 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { "-c".to_string(), "sleep 0.05; printf 'session output\\n'".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "session output\n"); @@ -247,17 +251,19 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { "-c".to_string(), "printf 'event output\\n'; sleep 0.1; printf 'event err\\n' >&2; sleep 0.1; exit 7".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let actual = collect_process_event_snapshots(process).await?; assert_eq!( actual, @@ -294,17 +300,19 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res "-c".to_string(), "printf 'late one\\n'; printf 'late two\\n'".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?; assert_eq!( @@ -342,17 +350,19 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG.to_string(), release_path.to_string_lossy().into_owned(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let exit_response = timeout( Duration::from_secs(2), @@ -415,19 +425,21 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { "-c".to_string(), "IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: true, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -452,12 +464,14 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re "-c".to_string(), "IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -465,7 +479,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tokio::time::sleep(Duration::from_millis(200)).await; let write_response = session.process.write(b"hello\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::Accepted); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let actual = collect_process_output_from_reads(process, wake_rx).await?; @@ -485,19 +499,21 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) "-c".to_string(), "sleep 0.3; if IFS= read -r line; then printf 'read:%s\\n' \"$line\"; else printf 'eof\\n'; fi".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); let write_response = session.process.write(b"ignored\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::StdinClosed); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -519,17 +535,19 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu "-c".to_string(), "trap 'printf \"signal:2\\n\"; exit 7' INT; printf 'ready\\n'; while :; do :; done".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let mut wake_rx = process.subscribe_wake(); let mut ready_output = String::new(); let mut after_seq = None; @@ -572,12 +590,14 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b "/C".to_string(), "echo ready && ping -n 30 127.0.0.1 >NUL".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -612,18 +632,20 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( "-c".to_string(), "printf 'queued output\\n'".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "queued output\n"); @@ -661,7 +683,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> ) .to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: HashMap::from([ ( @@ -676,6 +698,8 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; diff --git a/codex-rs/exec-server/tests/file_stream.rs b/codex-rs/exec-server/tests/file_stream.rs index e30e8c4689..89162daca4 100644 --- a/codex-rs/exec-server/tests/file_stream.rs +++ b/codex-rs/exec-server/tests/file_stream.rs @@ -45,7 +45,10 @@ async fn stream_stops_after_an_exact_block_boundary() -> Result<()> { std::fs::write(&path, vec![b'x'; BLOCK_SIZE * 2])?; let chunks = file_system - .read_file_stream(&PathUri::from_path(path)?, /*sandbox*/ None) + .read_file_stream( + &PathUri::from_host_native_path(path)?, + /*sandbox*/ None, + ) .await? .try_collect::>() .await?; @@ -64,7 +67,7 @@ async fn completed_streams_release_handle_capacity() -> Result<()> { let tmp = TempDir::new()?; let path = tmp.path().join("repeated.txt"); std::fs::write(&path, b"repeated")?; - let path = PathUri::from_path(path)?; + let path = PathUri::from_host_native_path(path)?; for _ in 0..=OPEN_FILE_LIMIT { let chunks = file_system @@ -88,7 +91,7 @@ async fn stream_rejects_platform_sandbox() -> Result<()> { let result = file_system .read_file_stream( - &PathUri::from_path(&path)?, + &PathUri::from_host_native_path(&path)?, Some(&read_only_sandbox(tmp.path().to_path_buf())), ) .await; @@ -120,7 +123,7 @@ async fn file_reads_reject_fifo_without_waiting_for_a_writer() -> Result<()> { ); } - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; let read_error = timeout( Duration::from_secs(1), file_system.read_file(&path_uri, /*sandbox*/ None), @@ -158,7 +161,7 @@ async fn file_reads_reject_named_pipes() -> Result<()> { let read_error = timeout( Duration::from_secs(1), file_system.read_file( - &PathUri::from_path(std::path::Path::new(&read_path))?, + &PathUri::from_host_native_path(std::path::Path::new(&read_path))?, /*sandbox*/ None, ), ) @@ -173,7 +176,7 @@ async fn file_reads_reject_named_pipes() -> Result<()> { let stream_result = timeout( Duration::from_secs(1), file_system.read_file_stream( - &PathUri::from_path(std::path::Path::new(&stream_path))?, + &PathUri::from_host_native_path(std::path::Path::new(&stream_path))?, /*sandbox*/ None, ), ) @@ -202,7 +205,10 @@ async fn stream_keeps_reading_the_open_file_after_path_replacement() -> Result<( let path = tmp.path().join("replaceable.bin"); std::fs::write(&path, vec![b'a'; BLOCK_SIZE + 1])?; let mut stream = file_system - .read_file_stream(&PathUri::from_path(&path)?, /*sandbox*/ None) + .read_file_stream( + &PathUri::from_host_native_path(&path)?, + /*sandbox*/ None, + ) .await?; assert_eq!( @@ -236,7 +242,7 @@ async fn read_block_supports_non_sequential_offsets_and_lengths() -> Result<()> let open = client .fs_open(FsOpenParams { handle_id: Uuid::new_v4().simple().to_string(), - path: PathUri::from_path(path)?, + path: PathUri::from_host_native_path(path)?, sandbox: None, }) .await?; @@ -295,7 +301,7 @@ async fn open_enforces_the_per_connection_limit_and_close_releases_capacity() -> let tmp = TempDir::new()?; let path = tmp.path().join("limited.bin"); std::fs::write(&path, b"limited")?; - let path = PathUri::from_path(path)?; + let path = PathUri::from_host_native_path(path)?; let mut handles = Vec::with_capacity(OPEN_FILE_LIMIT); for _ in 0..OPEN_FILE_LIMIT { let open = client @@ -359,7 +365,7 @@ async fn open_rejects_handle_ids_longer_than_32_bytes() -> Result<()> { let error = client .fs_open(FsOpenParams { handle_id: "x".repeat(33), - path: PathUri::from_path(path)?, + path: PathUri::from_host_native_path(path)?, sandbox: None, }) .await diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index 08ba2efdd8..8eb17f39fb 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -66,7 +66,10 @@ async fn file_system_get_metadata_reports_files_and_directories( std::fs::create_dir(&directory_path)?; let file_metadata = file_system - .get_metadata(&PathUri::from_path(&file_path)?, /*sandbox*/ None) + .get_metadata( + &PathUri::from_host_native_path(&file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -83,7 +86,10 @@ async fn file_system_get_metadata_reports_files_and_directories( assert!(file_metadata.modified_at_ms > 0); let directory_metadata = file_system - .get_metadata(&PathUri::from_path(&directory_path)?, /*sandbox*/ None) + .get_metadata( + &PathUri::from_host_native_path(&directory_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -116,7 +122,7 @@ async fn file_system_create_directory_creates_nested_directories( file_system .create_directory( - &PathUri::from_path(&nested_dir)?, + &PathUri::from_host_native_path(&nested_dir)?, CreateDirectoryOptions { recursive: true }, /*sandbox*/ None, ) @@ -140,7 +146,7 @@ async fn file_system_write_file_writes_bytes( let file_path = tmp.path().join("note.txt"); file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"hello from trait".to_vec(), /*sandbox*/ None, ) @@ -155,21 +161,21 @@ async fn file_system_write_file_writes_bytes( fn path_uri_join_and_parent_preserve_lexical_paths() -> Result<()> { let tmp = TempDir::new()?; let source_dir = tmp.path().join("source"); - let source_dir_uri = PathUri::from_path(&source_dir)?; + let source_dir_uri = PathUri::from_host_native_path(&source_dir)?; let joined_nested = source_dir_uri.join("nested/note.txt")?; assert_eq!( joined_nested, - PathUri::from_path(source_dir.join("nested").join("note.txt"))? + PathUri::from_host_native_path(source_dir.join("nested").join("note.txt"))? ); let joined_parent = joined_nested.parent(); assert_eq!( joined_parent, - Some(PathUri::from_path(source_dir.join("nested"))?) + Some(PathUri::from_host_native_path(source_dir.join("nested"))?) ); let joined_parent_traversal = source_dir_uri.join("../outside")?; assert_eq!( joined_parent_traversal, - PathUri::from_path(source_dir.join("../outside"))? + PathUri::from_host_native_path(source_dir.join("../outside"))? ); Ok(()) } @@ -188,7 +194,10 @@ async fn file_system_read_file_returns_bytes( std::fs::write(&file_path, "hello from trait")?; let contents = file_system - .read_file(&PathUri::from_path(&file_path)?, /*sandbox*/ None) + .read_file( + &PathUri::from_host_native_path(&file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!(contents, b"hello from trait"); @@ -213,7 +222,10 @@ async fn file_system_read_file_stream_returns_bounded_chunks( std::fs::write(&file_path, &contents)?; let chunks = file_system - .read_file_stream(&PathUri::from_path(file_path)?, /*sandbox*/ None) + .read_file_stream( + &PathUri::from_host_native_path(file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))? .try_collect::>() @@ -249,7 +261,10 @@ async fn file_system_read_file_text_returns_string( std::fs::write(&file_path, "hello from trait")?; let contents = file_system - .read_file_text(&PathUri::from_path(&file_path)?, /*sandbox*/ None) + .read_file_text( + &PathUri::from_host_native_path(&file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!(contents, "hello from trait"); @@ -271,8 +286,8 @@ async fn file_system_copy_copies_file(implementation: FileSystemImplementation) file_system .copy( - &PathUri::from_path(&source_file)?, - &PathUri::from_path(&copied_file)?, + &PathUri::from_host_native_path(&source_file)?, + &PathUri::from_host_native_path(&copied_file)?, CopyOptions { recursive: false }, /*sandbox*/ None, ) @@ -302,8 +317,8 @@ async fn file_system_copy_copies_directory_recursively( file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(&copied_dir)?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(&copied_dir)?, CopyOptions { recursive: true }, /*sandbox*/ None, ) @@ -332,7 +347,10 @@ async fn file_system_read_directory_lists_entries( std::fs::write(source_dir.join("root.txt"), "hello")?; let mut entries = file_system - .read_directory(&PathUri::from_path(&source_dir)?, /*sandbox*/ None) + .read_directory( + &PathUri::from_host_native_path(&source_dir)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; entries.sort_by(|left, right| left.file_name.cmp(&right.file_name)); @@ -370,7 +388,7 @@ async fn file_system_remove_removes_directory( file_system .remove( - &PathUri::from_path(&directory_path)?, + &PathUri::from_host_native_path(&directory_path)?, RemoveOptions { recursive: true, force: true, @@ -398,7 +416,7 @@ async fn file_system_write_file_reports_missing_parent( let error = match file_system .write_file( - &PathUri::from_path(&missing_parent_path)?, + &PathUri::from_host_native_path(&missing_parent_path)?, b"hello from trait".to_vec(), /*sandbox*/ None, ) @@ -432,8 +450,8 @@ async fn file_system_copy_rejects_directory_without_recursive( let error = file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(tmp.path().join("dest"))?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(tmp.path().join("dest"))?, CopyOptions { recursive: false }, /*sandbox*/ None, ) @@ -465,7 +483,7 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root( let sandbox = read_only_sandbox(allowed_dir); let metadata = file_system - .get_metadata(&PathUri::from_path(&file_path)?, Some(&sandbox)) + .get_metadata(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox)) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -481,7 +499,7 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root( ); let contents = file_system - .read_file(&PathUri::from_path(&file_path)?, Some(&sandbox)) + .read_file(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox)) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!(contents, b"sandboxed hello"); @@ -505,8 +523,8 @@ pub(crate) async fn assert_canonicalize_resolves_directory_alias( std::fs::write(&file_path, "canonical hello")?; create_directory_alias(&source_dir, &alias_dir)?; - let requested_path = PathUri::from_path(alias_dir.join("nested").join("note.txt"))?; - let expected_path = PathUri::from_path(std::fs::canonicalize(&file_path)?)?; + let requested_path = PathUri::from_host_native_path(alias_dir.join("nested").join("note.txt"))?; + let expected_path = PathUri::from_host_native_path(std::fs::canonicalize(&file_path)?)?; assert_ne!(requested_path, expected_path); let canonical_path = file_system @@ -535,8 +553,8 @@ pub(crate) async fn assert_sandboxed_canonicalize_resolves_directory_alias( create_directory_alias(&source_dir, &alias_dir)?; let sandbox = read_only_sandbox(tmp.path().to_path_buf()); - let requested_path = PathUri::from_path(alias_dir.join("nested").join("note.txt"))?; - let expected_path = PathUri::from_path(std::fs::canonicalize(&file_path)?)?; + let requested_path = PathUri::from_host_native_path(alias_dir.join("nested").join("note.txt"))?; + let expected_path = PathUri::from_host_native_path(std::fs::canonicalize(&file_path)?)?; assert_ne!(requested_path, expected_path); let canonical_path = file_system @@ -591,7 +609,7 @@ async fn file_system_sandboxed_write_allows_additional_write_root( file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"created".to_vec(), Some(&sandbox), ) @@ -617,8 +635,8 @@ async fn file_system_copy_rejects_copying_directory_into_descendant( let error = file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(source_dir.join("nested").join("copy"))?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(source_dir.join("nested").join("copy"))?, CopyOptions { recursive: true }, /*sandbox*/ None, ) diff --git a/codex-rs/exec-server/tests/file_system_unix.rs b/codex-rs/exec-server/tests/file_system_unix.rs index 57c3a82e97..0287844a70 100644 --- a/codex-rs/exec-server/tests/file_system_unix.rs +++ b/codex-rs/exec-server/tests/file_system_unix.rs @@ -187,7 +187,7 @@ async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result< file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"written through fs helper".to_vec(), Some(&sandbox), ) @@ -221,7 +221,10 @@ async fn file_system_get_metadata_reports_symlink_targets( let symlink_path = tmp.path().join("note-link.txt"); symlink(&file_path, &symlink_path)?; let symlink_metadata = file_system - .get_metadata(&PathUri::from_path(&symlink_path)?, /*sandbox*/ None) + .get_metadata( + &PathUri::from_host_native_path(&symlink_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -243,7 +246,7 @@ async fn file_system_get_metadata_reports_symlink_targets( symlink(&dir_path, &dir_symlink_path)?; let dir_symlink_metadata = file_system .get_metadata( - &PathUri::from_path(&dir_symlink_path)?, + &PathUri::from_host_native_path(&dir_symlink_path)?, /*sandbox*/ None, ) .await @@ -278,7 +281,7 @@ async fn file_system_sandboxed_write_rejects_unwritable_path( let sandbox = read_only_sandbox(tmp.path().to_path_buf()); let error = match file_system .write_file( - &PathUri::from_path(&blocked_path)?, + &PathUri::from_host_native_path(&blocked_path)?, b"nope".to_vec(), Some(&sandbox), ) @@ -314,7 +317,7 @@ async fn file_system_sandboxed_write_allows_explicit_alias_roots( file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"created".to_vec(), Some(&sandbox), ) @@ -345,7 +348,10 @@ async fn file_system_sandboxed_read_rejects_symlink_escape( let requested_path = allowed_dir.join("link").join("secret.txt"); let sandbox = read_only_sandbox(allowed_dir); let error = match file_system - .read_file(&PathUri::from_path(&requested_path)?, Some(&sandbox)) + .read_file( + &PathUri::from_host_native_path(&requested_path)?, + Some(&sandbox), + ) .await { Ok(_) => anyhow::bail!("read should be blocked"), @@ -375,7 +381,7 @@ async fn file_system_sandboxed_read_rejects_symlink_parent_dotdot_escape( symlink(&outside_dir, allowed_dir.join("link"))?; let requested_path = - PathUri::from_path(allowed_dir.join("link").join("..").join("secret.txt"))?; + PathUri::from_host_native_path(allowed_dir.join("link").join("..").join("secret.txt"))?; let sandbox = read_only_sandbox(allowed_dir); let error = match file_system.read_file(&requested_path, Some(&sandbox)).await { Ok(_) => anyhow::bail!("read should fail after path normalization"), @@ -411,7 +417,7 @@ async fn file_system_sandboxed_write_rejects_symlink_escape( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .write_file( - &PathUri::from_path(&requested_path)?, + &PathUri::from_host_native_path(&requested_path)?, b"nope".to_vec(), Some(&sandbox), ) @@ -449,7 +455,7 @@ async fn file_system_sandboxed_write_preserves_existing_hard_link( let sandbox = workspace_write_sandbox(allowed_dir); file_system .write_file( - &PathUri::from_path(&hard_link)?, + &PathUri::from_host_native_path(&hard_link)?, b"updated through existing hard link\n".to_vec(), Some(&sandbox), ) @@ -495,7 +501,7 @@ async fn file_system_create_directory_rejects_symlink_escape( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .create_directory( - &PathUri::from_path(&requested_path)?, + &PathUri::from_host_native_path(&requested_path)?, CreateDirectoryOptions { recursive: false }, Some(&sandbox), ) @@ -530,7 +536,10 @@ async fn file_system_read_directory_rejects_symlink_escape( let requested_path = allowed_dir.join("link"); let sandbox = read_only_sandbox(allowed_dir); let error = match file_system - .read_directory(&PathUri::from_path(&requested_path)?, Some(&sandbox)) + .read_directory( + &PathUri::from_host_native_path(&requested_path)?, + Some(&sandbox), + ) .await { Ok(_) => anyhow::bail!("read_directory should be blocked"), @@ -562,8 +571,8 @@ async fn file_system_copy_rejects_symlink_escape_destination( let sandbox = workspace_write_sandbox(allowed_dir.clone()); let error = match file_system .copy( - &PathUri::from_path(allowed_dir.join("source.txt"))?, - &PathUri::from_path(&requested_destination)?, + &PathUri::from_host_native_path(allowed_dir.join("source.txt"))?, + &PathUri::from_host_native_path(&requested_destination)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -600,7 +609,7 @@ async fn file_system_remove_removes_symlink_not_target( let sandbox = workspace_write_sandbox(allowed_dir); file_system .remove( - &PathUri::from_path(&symlink_path)?, + &PathUri::from_host_native_path(&symlink_path)?, RemoveOptions { recursive: false, force: false, @@ -640,8 +649,8 @@ async fn file_system_copy_preserves_symlink_source( let sandbox = workspace_write_sandbox(allowed_dir.clone()); file_system .copy( - &PathUri::from_path(&source_symlink)?, - &PathUri::from_path(&copied_symlink)?, + &PathUri::from_host_native_path(&source_symlink)?, + &PathUri::from_host_native_path(&copied_symlink)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -677,7 +686,7 @@ async fn file_system_remove_rejects_symlink_escape( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .remove( - &PathUri::from_path(&requested_path)?, + &PathUri::from_host_native_path(&requested_path)?, RemoveOptions { recursive: false, force: false, @@ -718,8 +727,8 @@ async fn file_system_copy_rejects_symlink_escape_source( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .copy( - &PathUri::from_path(&requested_source)?, - &PathUri::from_path(&requested_destination)?, + &PathUri::from_host_native_path(&requested_source)?, + &PathUri::from_host_native_path(&requested_destination)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -752,8 +761,8 @@ async fn file_system_copy_preserves_symlinks_in_recursive_copy( file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(&copied_dir)?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(&copied_dir)?, CopyOptions { recursive: true }, /*sandbox*/ None, ) @@ -798,8 +807,8 @@ async fn file_system_copy_ignores_unknown_special_files_in_recursive_copy( file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(&copied_dir)?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(&copied_dir)?, CopyOptions { recursive: true }, /*sandbox*/ None, ) @@ -837,8 +846,8 @@ async fn file_system_copy_rejects_standalone_fifo_source( let error = file_system .copy( - &PathUri::from_path(&fifo_path)?, - &PathUri::from_path(tmp.path().join("copied"))?, + &PathUri::from_host_native_path(&fifo_path)?, + &PathUri::from_host_native_path(tmp.path().join("copied"))?, CopyOptions { recursive: false }, /*sandbox*/ None, ) diff --git a/codex-rs/exec-server/tests/file_system_windows.rs b/codex-rs/exec-server/tests/file_system_windows.rs index fbf8b07330..1d5b4a13fe 100644 --- a/codex-rs/exec-server/tests/file_system_windows.rs +++ b/codex-rs/exec-server/tests/file_system_windows.rs @@ -74,7 +74,10 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> let readable_file = readonly_dir.join("readable.txt"); std::fs::write(&readable_file, b"readable")?; let read_result = file_system - .read_file(&PathUri::from_path(&readable_file)?, Some(&sandbox)) + .read_file( + &PathUri::from_host_native_path(&readable_file)?, + Some(&sandbox), + ) .await; // Some local Windows hosts cannot create restricted tokens. Reaching that // error still proves the remote fs helper went through the Windows sandbox @@ -87,7 +90,7 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> let blocked_file = readonly_dir.join("blocked.txt"); let error = file_system .write_file( - &PathUri::from_path(&blocked_file)?, + &PathUri::from_host_native_path(&blocked_file)?, b"blocked".to_vec(), Some(&sandbox), ) @@ -104,7 +107,7 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> fn read_only_sandbox_for_cwd(cwd: std::path::PathBuf) -> Result { Ok(FileSystemSandboxContext::from_legacy_sandbox_policy( SandboxPolicy::new_read_only_policy(), - PathUri::from_path(cwd)?, + PathUri::from_host_native_path(cwd)?, )?) } diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 571fd427fa..ba7f64a610 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -70,7 +70,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); @@ -135,7 +135,7 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-default-stdin") + process_id: ProcessId::from("proc-default-stdin"), } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5e58618410..30ce1459b7 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -144,18 +144,20 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { .exec(ExecParams { process_id: ProcessId::from("proc-1"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: None, env: HashMap::new(), tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!( response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); @@ -165,7 +167,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { std::fs::write(&large_file_path, &large_file_contents)?; let read_response = client .fs_read_file(FsReadFileParams { - path: PathUri::from_path(large_file_path)?, + path: PathUri::from_host_native_path(large_file_path)?, sandbox: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a04e258552..08c905c001 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -66,6 +66,7 @@ use codex_core::config::ConfigTomlLoadResult; use codex_core::config::find_codex_home; use codex_core::config::load_config_toml_with_layer_stack; use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use codex_core::config::resolve_bootstrap_auth_route_config; use codex_core::config::resolve_oss_provider; use codex_core::config::resolve_profile_v2_config_path; use codex_core::find_thread_meta_by_name_str; @@ -349,6 +350,14 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -357,6 +366,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); @@ -468,6 +478,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); + let auth_route_config = config.auth_route_config(); if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, @@ -475,6 +486,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + auth_route_config, }) .await { diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index e2f5ac666c..fe915746f9 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -76,7 +76,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -84,13 +84,13 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { namespace: None, arguments: "{}".to_string(), call_id: "mcp-call".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "mcp-call".to_string(), output: image_output("mcp"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -98,27 +98,27 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { call_id: "code-mode-call".to_string(), name: "exec".to_string(), input: String::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "code-mode-call".to_string(), name: Some("exec".to_string()), output: image_output("code-mode"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("generated-call".to_string()), status: "completed".to_string(), revised_prompt: None, result: "generated".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "orphan-call".to_string(), output: image_output("orphan"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -206,7 +206,7 @@ async fn recent_image_fallback_requires_requested_count() { role: "user".to_string(), content: vec![input_image("only-image")], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], &[], ) diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 3e2cfd1792..d4451a8926 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -256,7 +256,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::CustomToolCall { call_id, .. } => { custom_tool_call_ids.insert(call_id.as_str()); } - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -296,7 +297,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { image_urls.push(format!("data:image/png;base64,{result}")); } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index b154b060b2..07d6621856 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -18,6 +18,7 @@ use codex_plugin::manifest::PluginManifest; use codex_plugin::manifest::PluginManifestMcpServers; use codex_plugin::manifest::PluginManifestPaths; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -169,7 +170,7 @@ async fn reads_declared_config_only_through_executor_file_system() { args: Vec::new(), env: None, env_vars: Vec::new(), - cwd: Some(plugin_root.to_path_buf()), + cwd: Some(LegacyAppPathString::from_path(plugin_root.as_path())), }, environment_id: "executor-test".to_string(), enabled: true, @@ -223,7 +224,7 @@ async fn reads_manifest_object_config_without_executor_file_system_access() { args: Vec::new(), env: None, env_vars: Vec::new(), - cwd: Some(plugin_root.to_path_buf()), + cwd: Some(LegacyAppPathString::from_path(plugin_root.as_path())), }, environment_id: "executor-test".to_string(), enabled: true, diff --git a/codex-rs/ext/skills/src/provider/orchestrator.rs b/codex-rs/ext/skills/src/provider/orchestrator.rs index 045b67de62..8d97bf32a1 100644 --- a/codex-rs/ext/skills/src/provider/orchestrator.rs +++ b/codex-rs/ext/skills/src/provider/orchestrator.rs @@ -28,7 +28,6 @@ const MAX_RESOURCE_PAGES: usize = 10; const MAX_ORCHESTRATOR_SKILLS: usize = 100; const MAX_SKILL_NAME_CHARS: usize = 64; const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128; -const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024; const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024; const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048; const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024; @@ -308,12 +307,17 @@ fn normalized_label(value: &str, max_chars: usize) -> Option { } fn normalized_description(value: &str) -> Option { - normalized_single_line(value, MAX_SKILL_DESCRIPTION_CHARS).map(|value| { + let value = value.split_whitespace().collect::>().join(" "); + if value.chars().any(char::is_control) { + return None; + } + + Some( value .replace('&', "&") .replace('<', "<") - .replace('>', ">") - }) + .replace('>', ">"), + ) } fn normalized_single_line(value: &str, max_chars: usize) -> Option { diff --git a/codex-rs/ext/skills/src/render.rs b/codex-rs/ext/skills/src/render.rs index 89597aa9bb..dda32b4864 100644 --- a/codex-rs/ext/skills/src/render.rs +++ b/codex-rs/ext/skills/src/render.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use codex_utils_string::take_bytes_at_char_boundary; use crate::catalog::SkillCatalog; @@ -7,6 +9,8 @@ use crate::fragments::AvailableSkillsInstructions; const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000; const MAX_MAIN_PROMPT_BYTES: usize = 8_000; +const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024; +const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "..."; pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256; pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024; @@ -31,7 +35,8 @@ pub(crate) fn available_skills_fragment( .short_description .as_deref() .unwrap_or(entry.description.as_str()); - let line = render_skill_line(entry, description); + let description = truncate_catalog_skill_description(description); + let line = render_skill_line(entry, description.as_ref()); let next_bytes = total_bytes.saturating_add(line.len()); if next_bytes > MAX_AVAILABLE_SKILLS_BYTES { omitted = omitted.saturating_add(1); @@ -54,6 +59,26 @@ pub(crate) fn available_skills_fragment( Some(AvailableSkillsInstructions::from_skill_lines(skill_lines)) } +pub(crate) fn truncate_catalog_skill_description(description: &str) -> Cow<'_, str> { + if description + .char_indices() + .nth(MAX_CATALOG_SKILL_DESCRIPTION_CHARS) + .is_none() + { + return Cow::Borrowed(description); + } + + let prefix_chars = MAX_CATALOG_SKILL_DESCRIPTION_CHARS + .saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count()); + let prefix_end = description + .char_indices() + .nth(prefix_chars) + .map_or(description.len(), |(index, _)| index); + let mut truncated = description[..prefix_end].to_string(); + truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX); + Cow::Owned(truncated) +} + fn render_skill_line(entry: &SkillCatalogEntry, description: &str) -> String { let locator_kind = match &entry.authority.kind { SkillSourceKind::Host => "file", diff --git a/codex-rs/ext/skills/src/tools/list.rs b/codex-rs/ext/skills/src/tools/list.rs index 31cb68003d..e556f9783f 100644 --- a/codex-rs/ext/skills/src/tools/list.rs +++ b/codex-rs/ext/skills/src/tools/list.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use serde::Serialize; use crate::catalog::SkillCatalogEntry; +use crate::render::truncate_catalog_skill_description; use crate::render::truncate_utf8_to_bytes; use super::MAX_HANDLE_BYTES; @@ -95,7 +96,7 @@ fn listed_skill(entry: SkillCatalogEntry) -> Option { authority, package: entry.id.0, name: entry.name, - description: entry.description, + description: truncate_catalog_skill_description(&entry.description).into_owned(), main_resource: entry.main_prompt.as_str().to_string(), }) } diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 2d06c33636..8b02558347 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -10,10 +10,14 @@ use codex_core_skills::SKILLS_INTRO_WITH_ABSOLUTE_PATHS; use codex_core_skills::SkillLoadOutcome; use codex_core_skills::SkillMetadata; use codex_core_skills::injection::InjectedHostSkillPrompts; +use codex_extension_api::ConversationHistory; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolPayload; use codex_extension_api::TurnInputContext; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -23,6 +27,7 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TruncationPolicy; use codex_protocol::user_input::UserInput; use codex_skills_extension::SkillProviders; use codex_skills_extension::SkillsExtensionConfig; @@ -255,6 +260,128 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in Ok(()) } +#[tokio::test] +async fn default_context_truncates_catalog_descriptions() -> TestResult { + let description = "x".repeat(1_025); + let mut entry = test_entry( + SkillSourceKind::Orchestrator, + "codex_apps", + "orchestrator/long-description", + "skill://orchestrator/long-description/SKILL.md", + ); + entry.description = description.clone(); + let providers = + SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![entry], + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: None, + fail_first_list: false, + })); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config(); + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + environments: &[], + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let fragments = registry.context_contributors()[0] + .contribute_thread_context(&session_store, &thread_store) + .await; + assert_eq!(1, fragments.len()); + let rendered = fragments[0].text(); + assert!(rendered.contains(&("x".repeat(1_021) + "..."))); + assert!(!rendered.contains(&"x".repeat(1_024))); + assert!(!rendered.contains(&description)); + + Ok(()) +} + +#[tokio::test] +async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResult { + let description = "x".repeat(1_025); + let mut entry = test_entry( + SkillSourceKind::Orchestrator, + "codex_apps", + "orchestrator/long-description", + "skill://orchestrator/long-description/SKILL.md", + ); + entry.description = description.clone(); + let providers = + SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![entry], + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: None, + fail_first_list: false, + })); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config(); + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + environments: &[], + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let tools = registry.tool_contributors()[0].tools(&session_store, &thread_store); + let list_tool = tools + .iter() + .find(|tool| tool.tool_name().name == "list") + .ok_or("skills.list tool should be registered")?; + let payload = ToolPayload::Function { + arguments: serde_json::json!({"authority": {"kind": "orchestrator"}}).to_string(), + }; + let output = list_tool + .handle(ToolCall { + turn_id: "turn-1".to_string(), + call_id: "call-1".to_string(), + tool_name: list_tool.tool_name(), + model: "gpt-test".to_string(), + truncation_policy: TruncationPolicy::Bytes(1_024), + conversation_history: ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), + environments: Vec::new(), + payload: payload.clone(), + }) + .await?; + let response = output + .post_tool_use_response("call-1", &payload) + .ok_or("skills.list should expose structured output")?; + let rendered_description = response["skills"][0]["description"] + .as_str() + .ok_or("skills.list response should include a description")?; + + assert_eq!(rendered_description, "x".repeat(1_021) + "..."); + assert_ne!(rendered_description, description); + + Ok(()) +} + #[tokio::test] async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult { let list_calls = Arc::new(AtomicUsize::new(0)); diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index df3faf123c..10fb0dd65e 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -36,7 +36,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { ResponseItem::AgentMessage { author, content, - metadata, + internal_chat_message_metadata_passthrough: metadata, .. } => { if let Some(text) = plaintext_agent_message_content(content) { @@ -47,7 +47,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { text: format!("Agent message from {author}:\n{text}"), }], phase: None, - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }); } } @@ -56,7 +56,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } if role == USER_ROLE && matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) => { @@ -71,7 +71,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { role: role.clone(), content, phase: phase.clone(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }); } } @@ -104,7 +104,7 @@ mod tests { } }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -125,7 +125,7 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, previous_assistant, message("developer", "developer"), @@ -158,7 +158,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let items = vec![ previous_user, diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index 13a6bc63a3..7e5f626d8d 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -18,7 +18,6 @@ const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks"; const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks"; const COMMAND_SKILL_PREFIX: &str = "source-command"; const MAX_SKILL_NAME_LEN: usize = 64; -const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; #[derive(Debug)] struct ParsedDocument { @@ -1130,14 +1129,11 @@ fn command_skill_name_if_supported( return None; } let source_name = command_source_name(source_commands, source_file); - let description = command_skill_description(document, &source_name)?; + command_skill_description(document, &source_name)?; let name = command_skill_name(source_commands, source_file); if name.chars().count() > MAX_SKILL_NAME_LEN { return None; } - if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN { - return None; - } if has_unsupported_command_template_features(&document.body) { return None; } @@ -1388,6 +1384,8 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; + fn source_path(relative_path: &str) -> PathBuf { Path::new("/repo") .join(external_agent_config_dir()) @@ -1722,6 +1720,35 @@ command = "enabled-server" assert!(command_skill_name_if_supported(&root, &file, &document).is_none()); } + #[test] + fn commands_with_overlong_descriptions_are_preserved() { + let root = source_path("commands"); + let file = source_path("commands/review.md"); + let description = "x".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let document = + parse_document_content(&format!("---\ndescription: {description}\n---\nReview\n")); + + assert_eq!( + command_skill_name_if_supported(&root, &file, &document), + Some("source-command-review".to_string()) + ); + + let rendered = render_command_skill( + &document.body, + "source-command-review", + &description, + "review", + ); + let rendered_document = parse_document_content(&rendered); + assert_eq!( + rendered_document + .frontmatter + .get("description") + .and_then(FrontmatterValue::as_scalar), + Some(description.as_str()) + ); + } + #[test] fn commands_with_provider_runtime_expansion_are_skipped() { let root = source_path("commands"); diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 2990c0f5ea..6391d27bd3 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -141,7 +141,7 @@ fn response_item(message: ConversationMessage) -> ResponseItem { }, content: vec![content], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index dc78d8cd43..35b669b0a0 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -46,6 +46,7 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0, max = 3600000))] pub default_wait_timeout_ms: Option, + /// Deprecated compatibility field. Its value is ignored. #[serde(skip_serializing_if = "Option::is_none")] pub usage_hint_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -73,6 +74,32 @@ impl FeatureConfig for MultiAgentV2ConfigToml { } } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TokenBudgetConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Number of tokens remaining before auto-compaction when the wrap-up reminder is emitted. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub reminder_threshold_tokens: Option, + /// Reminder template. `{n_remaining}` is replaced with the tokens remaining before + /// auto-compaction. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 1000))] + pub reminder_message_template: Option, +} + +impl FeatureConfig for TokenBudgetConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RolloutBudgetConfigToml { @@ -82,8 +109,8 @@ pub struct RolloutBudgetConfigToml { #[schemars(range(min = 1))] pub limit_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1))] - pub reminder_interval_tokens: Option, + /// Remaining weighted-token values that trigger reminders when crossed. + pub reminder_at_remaining_tokens: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0.0))] pub sampling_token_weight: Option, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3bba6faf98..86fdd2e29e 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -26,6 +26,7 @@ pub use feature_configs::NetworkProxyModeToml; pub use feature_configs::NetworkProxyUnixSocketPermissionToml; use feature_configs::RemovedAppsMcpPathOverrideConfigToml; pub use feature_configs::RolloutBudgetConfigToml; +pub use feature_configs::TokenBudgetConfigToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -144,7 +145,7 @@ pub enum Feature { Collab, /// Enable task-path-based multi-agent routing. MultiAgentV2, - /// Enable per-turn multi-agent mode selection. + /// Removed compatibility flag retained as a no-op. MultiAgentMode, /// Enable CSV-backed agent job tools. SpawnCsv, @@ -156,7 +157,7 @@ pub enum Feature { AppsMcpPathOverride, /// Removed compatibility flag retained as a no-op now that tool_search is always enabled. ToolSearch, - /// Always defer MCP tools behind tool_search instead of exposing small sets directly. + /// Removed compatibility flag. MCP tools are always deferred when tool_search is available. ToolSearchAlwaysDeferMcpTools, /// Expose MCP model-visible namespaces without the legacy `mcp__` prefix. NonPrefixedMcpToolNames, @@ -174,6 +175,10 @@ pub enum Feature { /// /// Requirements-only gate: this should be set from requirements, not user config. BrowserUse, + /// Allow Browser Use integration to access the full Chrome DevTools Protocol surface. + /// + /// Requirements-only gate: this should be set from requirements, not user config. + BrowserUseFullCdpAccess, /// Allow Browser Use integration with external browsers. /// /// Requirements-only gate: this should be set from requirements, not user config. @@ -192,7 +197,7 @@ pub enum Feature { ImageGeneration, /// Replace hosted image generation with the standalone image-generation extension. ImageGenExt, - /// Resize all inline data-URL images before recording them in history. + /// Removed compatibility flag for always-on centralized image preparation. ResizeAllImages, /// Generate Responses API item IDs for client-created history items. ItemIds, @@ -230,6 +235,8 @@ pub enum Feature { RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, + /// Enable automatic context compaction before or during a turn. + AutoCompaction, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Use Agent Identity for ChatGPT-authenticated sessions. @@ -466,10 +473,10 @@ impl Features { "apply_patch_freeform" => { continue; } - "tool_search" | "apps_mcp_path_override" => { + "tool_search" | "tool_search_always_defer_mcp_tools" | "apps_mcp_path_override" => { continue; } - "image_detail_original" => { + "image_detail_original" | "resize_all_images" => { continue; } "plugin_hooks" => { @@ -632,6 +639,8 @@ pub struct FeaturesToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_budget: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub rollout_budget: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_time_reminder: Option>, @@ -667,6 +676,9 @@ impl FeaturesToml { if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } + if let Some(enabled) = self.token_budget.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::TokenBudget.key().to_string(), enabled); + } if let Some(enabled) = self.rollout_budget.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::RolloutBudget.key().to_string(), enabled); } @@ -688,6 +700,7 @@ impl FeaturesToml { let Self { code_mode, multi_agent_v2, + token_budget, rollout_budget, current_time_reminder, removed_apps_mcp_path_override: _, @@ -703,6 +716,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(code_mode, enabled); } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); + } else if spec.id == Feature::TokenBudget { + materialize_resolved_feature_enabled(token_budget, enabled); } else if spec.id == Feature::RolloutBudget { materialize_resolved_feature_enabled(rollout_budget, enabled); } else if spec.id == Feature::CurrentTimeReminder { @@ -1023,7 +1038,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::MultiAgentMode, key: "multi_agent_mode", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { @@ -1059,8 +1074,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolSearchAlwaysDeferMcpTools, key: "tool_search_always_defer_mcp_tools", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Removed, + default_enabled: true, }, FeatureSpec { id: Feature::NonPrefixedMcpToolNames, @@ -1104,6 +1119,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::BrowserUseFullCdpAccess, + key: "browser_use_full_cdp_access", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::BrowserUseExternal, key: "browser_use_external", @@ -1149,8 +1170,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ResizeAllImages, key: "resize_all_images", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Removed, + default_enabled: true, }, FeatureSpec { id: Feature::ItemIds, @@ -1326,6 +1347,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::AutoCompaction, + key: "auto_compaction", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7ee46f4ba2..69fd59e13a 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -476,6 +476,23 @@ fn from_sources_ignores_removed_image_detail_original_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_resize_all_images_feature_key() { + let features_toml = + FeaturesToml::from(BTreeMap::from([("resize_all_images".to_string(), false)])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn from_sources_ignores_removed_undo_feature_key() { let features_toml = FeaturesToml::from(BTreeMap::from([("undo".to_string(), true)])); @@ -544,6 +561,25 @@ fn from_sources_ignores_removed_plugin_hooks_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_tool_search_always_defer_mcp_tools_feature_key() { + let features_toml = FeaturesToml::from(BTreeMap::from([( + "tool_search_always_defer_mcp_tools".to_string(), + false, + )])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn multi_agent_v2_feature_config_deserializes_boolean_toggle() { let features: FeaturesToml = toml::from_str( @@ -604,45 +640,6 @@ non_code_mode_only = true ); } -#[test] -fn multi_agent_v2_feature_config_usage_hint_enabled_does_not_enable_feature() { - let features_toml: FeaturesToml = toml::from_str( - r#" -[multi_agent_v2] -usage_hint_enabled = false -"#, - ) - .expect("features table should deserialize"); - let features = Features::from_sources( - FeatureConfigSource { - features: Some(&features_toml), - ..Default::default() - }, - FeatureConfigSource::default(), - FeatureOverrides::default(), - ); - - assert_eq!(features.enabled(Feature::MultiAgentV2), false); - assert_eq!(features_toml.entries(), BTreeMap::new()); - assert_eq!( - features_toml.multi_agent_v2, - Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml { - enabled: None, - max_concurrent_threads_per_session: None, - min_wait_timeout_ms: None, - max_wait_timeout_ms: None, - default_wait_timeout_ms: None, - usage_hint_enabled: Some(false), - usage_hint_text: None, - root_agent_usage_hint_text: None, - subagent_usage_hint_text: None, - tool_namespace: None, - hide_spawn_agent_metadata: None, - non_code_mode_only: None, - })) - ); -} - #[test] fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config() { let mut features = Features::with_defaults(); diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 370af883c0..79ee2e7f54 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -3,6 +3,9 @@ use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; use codex_agent_identity::ChatGptEnvironment; +use codex_agent_identity::agent_identity_jwks_url; +use codex_agent_identity::agent_registration_url; +use codex_agent_identity::agent_task_registration_url; use codex_agent_identity::build_abom; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; @@ -15,7 +18,8 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::SessionSource; use thiserror::Error; -use crate::default_client::build_reqwest_client; +use crate::default_client::build_default_auth_reqwest_client; +use crate::outbound_proxy::AuthRouteConfig; use super::storage::AgentIdentityAuthRecord; @@ -86,7 +90,7 @@ pub struct AgentIdentityAuth { pub(super) struct ManagedChatGptAgentIdentityBinding { pub(super) account_id: String, pub(super) chatgpt_user_id: String, - pub(super) email: String, + pub(super) email: Option, pub(super) plan_type: AccountPlanType, pub(super) chatgpt_account_is_fedramp: bool, pub(super) access_token: String, @@ -96,13 +100,18 @@ impl AgentIdentityAuth { pub async fn from_record( mut record: AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key) .map_err(std::io::Error::other)?; if record_needs_task_registration(&record) { record.task_id = Some( - register_task_for_record_with_retries(&record, agent_identity_authapi_base_url) - .await?, + register_task_for_record_with_retries( + &record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, ); } Ok(Self { @@ -114,9 +123,10 @@ impl AgentIdentityAuth { jwt: &str, chatgpt_base_url: &str, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { - let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?; - Self::from_record(record, agent_identity_authapi_base_url).await + let record = verified_record_from_jwt(jwt, chatgpt_base_url, auth_route_config).await?; + Self::from_record(record, agent_identity_authapi_base_url, auth_route_config).await } #[cfg(test)] @@ -146,8 +156,8 @@ impl AgentIdentityAuth { &self.record.chatgpt_user_id } - pub fn email(&self) -> &str { - &self.record.email + pub fn email(&self) -> Option<&str> { + self.record.email.as_deref() } pub fn plan_type(&self) -> AccountPlanType { @@ -163,9 +173,11 @@ pub(super) async fn register_managed_chatgpt_agent_identity( binding: ManagedChatGptAgentIdentityBinding, agent_identity_authapi_base_url: &str, session_source: SessionSource, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let key_material = generate_agent_key_material().map_err(std::io::Error::other)?; - let client = build_reqwest_client(); + let registration_url = agent_registration_url(agent_identity_authapi_base_url); + let client = build_default_auth_reqwest_client(®istration_url, auth_route_config)?; let runtime_id = retry_registration(|| async { register_agent_identity( &client, @@ -200,7 +212,7 @@ pub(super) async fn register_managed_chatgpt_agent_identity( chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp, task_id: None, }; - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) + AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config) .await .map_err(|err| classify_bootstrap_error("agent task registration", err)) } @@ -208,9 +220,12 @@ pub(super) async fn register_managed_chatgpt_agent_identity( pub(super) async fn verified_record_from_jwt( jwt: &str, chatgpt_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; - let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url) + let jwks_url = agent_identity_jwks_url(chatgpt_base_url); + let client = build_default_auth_reqwest_client(&jwks_url, auth_route_config)?; + let jwks = fetch_agent_identity_jwks(&client, chatgpt_base_url) .await .map_err(std::io::Error::other)?; let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?; @@ -285,19 +300,24 @@ where async fn register_task_for_record_with_retries( record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { + let task_registration_url = + agent_task_registration_url(agent_identity_authapi_base_url, &record.agent_runtime_id); + let client = build_default_auth_reqwest_client(&task_registration_url, auth_route_config)?; retry_registration(|| async { - register_task_for_record(record, agent_identity_authapi_base_url).await + register_task_for_record(&client, record, agent_identity_authapi_base_url).await }) .await } async fn register_task_for_record( + client: &reqwest::Client, record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, ) -> std::io::Result { register_agent_task( - &build_reqwest_client(), + client, agent_identity_authapi_base_url, key_for_record(record), ) @@ -343,7 +363,7 @@ mod tests { agent_private_key: private_key, account_id: "account-1".to_string(), chatgpt_user_id: "user-1".to_string(), - email: "agent@example.com".to_string(), + email: Some("agent@example.com".to_string()), plan_type: AccountPlanType::Plus, chatgpt_account_is_fedramp: false, task_id: None, @@ -370,6 +390,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -414,6 +435,7 @@ mod tests { &jwt, &format!("{}/backend-api", server.uri()), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -456,6 +478,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index b52f03b706..bcce562e69 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -122,6 +122,7 @@ async fn login_with_access_token_writes_agent_identity_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("login_with_access_token should succeed"); @@ -181,6 +182,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -226,6 +228,7 @@ async fn login_with_access_token_writes_only_personal_access_token() { Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -278,6 +281,7 @@ async fn login_with_access_token_rejects_personal_access_token_workspace_mismatc Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("personal access token workspace mismatch should fail"); @@ -310,6 +314,7 @@ async fn login_with_access_token_rejects_invalid_personal_access_token() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid personal access token should fail"); @@ -333,6 +338,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid access token should fail"); @@ -364,6 +370,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -373,6 +380,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::JwtOnly, /*agent_identity_authapi_base_url*/ None, /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -403,6 +411,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -412,6 +421,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -442,6 +452,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should reload"); @@ -450,6 +461,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -482,6 +494,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -510,6 +523,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -546,6 +560,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -563,6 +578,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -591,7 +607,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() )?; let mut record = agent_identity_record("account-123"); record.chatgpt_user_id = "user-12345".to_string(); - record.email = "user@example.com".to_string(); + record.email = Some("user@example.com".to_string()); let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let auth_path = get_auth_file(codex_home.path()); let mut auth_json = storage.try_read_auth_json(&auth_path)?; @@ -605,6 +621,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -625,6 +642,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -659,6 +677,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -676,6 +695,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -717,6 +737,7 @@ async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> an &agent_identity, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect_err("agent identity jwt task retry exhaustion should fail"); @@ -747,6 +768,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("unsigned access token should fail"); @@ -768,6 +790,7 @@ async fn missing_auth_json_returns_none() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("call should succeed"); @@ -797,6 +820,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -858,6 +882,7 @@ async fn loads_api_key_from_auth_json() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -908,6 +933,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let managed = UnauthorizedRecovery { @@ -952,6 +978,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -972,6 +999,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("updated auth should parse"); @@ -1277,6 +1305,7 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, + auth_route_config: None, } } @@ -1359,6 +1388,7 @@ async fn load_auth_reads_access_token_from_env() { Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1406,6 +1436,7 @@ async fn load_auth_reads_personal_access_token_from_env() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1459,6 +1490,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1498,6 +1530,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1509,6 +1542,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1542,6 +1576,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -1574,6 +1609,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1670,6 +1706,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1681,6 +1718,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, + auth_route_config: None, }; let err = super::enforce_login_restrictions(&config) @@ -1804,6 +1842,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), + auth_route_config: None, }; let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url( @@ -1887,7 +1926,7 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord { agent_private_key: key_material.private_key_pkcs8_base64, account_id: account_id.to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, task_id: None, @@ -2056,6 +2095,7 @@ async fn assert_agent_identity_plan_alias( &jwt, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect("agent identity auth"); @@ -2087,6 +2127,7 @@ async fn plan_type_maps_known_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2118,6 +2159,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2152,6 +2194,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2186,6 +2229,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2217,6 +2261,7 @@ async fn missing_plan_type_maps_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 691d8bbc5d..7db2672ce3 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -63,6 +63,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -111,6 +112,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -135,6 +137,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index ee51edf9b5..f72727bfca 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -5,8 +5,11 @@ //! workspace. use codex_client::BuildCustomCaTransportError; +use codex_client::BuildRouteAwareHttpClientError; +use codex_client::ClientRouteClass; use codex_client::CodexHttpClient; pub use codex_client::CodexRequestBuilder; +use codex_client::build_reqwest_client_for_route; use codex_client::build_reqwest_client_with_custom_ca; use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; @@ -17,6 +20,8 @@ use std::sync::LazyLock; use std::sync::Mutex; use std::sync::RwLock; +use crate::outbound_proxy::AuthRouteConfig; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -189,6 +194,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { } /// Create an HTTP client with default `originator` and `User-Agent` headers set. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. pub fn create_client() -> CodexHttpClient { let inner = build_reqwest_client(); CodexHttpClient::new(inner) @@ -200,6 +208,10 @@ pub fn create_client() -> CodexHttpClient { /// policy, then layers in shared custom CA handling from `CODEX_CA_CERTIFICATE` / /// `SSL_CERT_FILE`. The function remains infallible for compatibility with existing call sites, so /// a custom-CA or builder failure is logged and falls back to `reqwest::Client::new()`. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. Auth callers with route settings must use +/// `build_default_auth_reqwest_client` or `create_default_auth_client`. pub fn build_reqwest_client() -> reqwest::Client { try_build_reqwest_client().unwrap_or_else(|error| { tracing::warn!(error = %error, "failed to build default reqwest client"); @@ -220,13 +232,58 @@ pub fn build_reqwest_client() -> reqwest::Client { /// Callers that need a structured CA-loading failure instead of the legacy logged fallback can use /// this method directly. pub fn try_build_reqwest_client() -> Result { + build_reqwest_client_with_custom_ca(default_reqwest_client_builder()) +} + +fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { builder = builder.no_proxy(); } - builder = with_chatgpt_cloudflare_cookie_store(builder); + with_chatgpt_cloudflare_cookie_store(builder) +} - build_reqwest_client_with_custom_ca(builder) +/// Builds a raw reqwest client for an auth endpoint without Codex default headers. +pub(crate) fn build_raw_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_reqwest_client_for_route( + reqwest::Client::builder(), + endpoint, + ClientRouteClass::Auth, + auth_route_config.map(AuthRouteConfig::route_config), + ) +} + +/// Builds the default Codex reqwest client for an auth endpoint. +pub(crate) fn build_default_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else { + return Ok(build_reqwest_client()); + }; + + if is_sandboxed() { + // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed + // separately through network-proxy. + return Ok(build_reqwest_client()); + } + build_reqwest_client_for_route( + default_reqwest_client_builder(), + endpoint, + ClientRouteClass::Auth, + Some(route_config), + ) +} + +/// Builds the default Codex HTTP client wrapper for an auth endpoint. +pub(crate) fn create_default_auth_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new) } pub fn default_headers() -> HeaderMap { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 138bdad5b9..26580a672d 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -49,6 +49,8 @@ use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; @@ -237,6 +239,7 @@ impl CodexAuth { chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); if auth_mode == ApiAuthMode::ApiKey { @@ -263,14 +266,18 @@ impl CodexAuth { &jwt, &base_url, agent_identity_authapi_base_url, + auth_route_config, ) .await?; return Ok(Self::AgentIdentity(auth)); } AgentIdentityStorage::Record(record) => { - let auth = - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?; return Ok(Self::AgentIdentity(auth)); } } @@ -281,7 +288,8 @@ impl CodexAuth { "personal access token auth is missing a personal access token.", )); }; - return Self::from_personal_access_token(personal_access_token).await; + return Self::from_personal_access_token(personal_access_token, auth_route_config) + .await; } if auth_mode == ApiAuthMode::BedrockApiKey { let Some(auth) = auth_dot_json.bedrock_api_key else { @@ -293,9 +301,10 @@ impl CodexAuth { } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); + let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?; let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client: create_client(), + client, }; match auth_mode { @@ -324,6 +333,7 @@ impl CodexAuth { auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url).ok(); @@ -335,6 +345,7 @@ impl CodexAuth { chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config, ) .await } @@ -342,12 +353,14 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?; Self::from_agent_identity_jwt_with_authapi_base_url( jwt, chatgpt_base_url, &agent_identity_authapi_base_url, + auth_route_config, ) .await } @@ -356,19 +369,29 @@ impl CodexAuth { jwt: &str, chatgpt_base_url: Option<&str>, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let base_url = chatgpt_base_url .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); Ok(Self::AgentIdentity( - AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?, + AgentIdentityAuth::from_jwt( + jwt, + &base_url, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, )) } - pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + pub async fn from_personal_access_token( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { Ok(Self::PersonalAccessToken( - PersonalAccessTokenAuth::load(access_token).await?, + PersonalAccessTokenAuth::load(access_token, auth_route_config).await?, )) } @@ -483,8 +506,8 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account email. pub fn get_account_email(&self) -> Option { match self { - Self::AgentIdentity(auth) => Some(auth.email().to_string()), - Self::PersonalAccessToken(auth) => Some(auth.email().to_string()), + Self::AgentIdentity(auth) => auth.email().map(str::to_string), + Self::PersonalAccessToken(auth) => auth.email().map(str::to_string), _ => self.get_current_token_data().and_then(|t| t.id_token.email), } } @@ -568,6 +591,7 @@ impl CodexAuth { policy: AgentIdentityAuthPolicy, agent_identity_authapi_base_url: Option<&str>, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result> { match self { @@ -583,6 +607,7 @@ impl CodexAuth { self.ensure_managed_chatgpt_agent_identity( require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, forced_chatgpt_workspace_id, + auth_route_config, session_source, ) .await @@ -595,6 +620,7 @@ impl CodexAuth { &self, agent_identity_authapi_base_url: &str, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result { let binding = @@ -607,9 +633,13 @@ impl CodexAuth { && record_matches_managed_chatgpt_binding(&record, &binding) { let should_persist = record_needs_task_registration(&record); - let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await - .map_err(|err| classify_bootstrap_error("agent task registration", err))?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await + .map_err(|err| classify_bootstrap_error("agent task registration", err))?; if should_persist { self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; } @@ -620,6 +650,7 @@ impl CodexAuth { binding, agent_identity_authapi_base_url, session_source, + auth_route_config, ) .await?; self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; @@ -692,7 +723,7 @@ impl ManagedChatGptAgentIdentityBinding { Some(Self { account_id, chatgpt_user_id, - email: token_data.id_token.email.clone().unwrap_or_default(), + email: token_data.id_token.email.clone(), plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown), chatgpt_account_is_fedramp: auth.is_fedramp_account(), access_token: token_data.access_token, @@ -789,6 +820,7 @@ pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_dot_json = match load_auth_dot_json( codex_home, @@ -801,7 +833,7 @@ pub async fn logout_with_revoke( None } }; - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } logout_all_stores( @@ -843,10 +875,11 @@ pub async fn login_with_access_token( forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result<()> { let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; AuthDotJson { // Infer PAT auth from the credential field so older Codex builds can still @@ -865,7 +898,7 @@ pub async fn login_with_access_token( .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - verified_record_from_jwt(jwt, &base_url).await?; + verified_record_from_jwt(jwt, &base_url, auth_route_config).await?; AuthDotJson { auth_mode: Some(ApiAuthMode::AgentIdentity), openai_api_key: None, @@ -954,8 +987,10 @@ pub struct AuthConfig { pub forced_login_method: Option, pub chatgpt_base_url: Option, pub forced_chatgpt_workspace_id: Option>, + pub auth_route_config: Option, } +/// Enforces configured login restrictions using auth-owned HTTP settings. pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok(); @@ -978,6 +1013,7 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, agent_identity_authapi_base_url, + config.auth_route_config.as_ref(), ) .await? else { @@ -1113,6 +1149,7 @@ fn logout_all_stores( Ok(removed_ephemeral || removed_managed) } +#[allow(clippy::too_many_arguments)] async fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, @@ -1121,6 +1158,7 @@ async fn load_auth( chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -1142,6 +1180,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1153,7 +1192,7 @@ async fn load_auth( if let Some(access_token) = read_codex_access_token_from_env() { return match classify_codex_access_token(&access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } @@ -1162,6 +1201,7 @@ async fn load_auth( jwt, chatgpt_base_url, require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, + auth_route_config, ) } .await @@ -1192,6 +1232,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1237,7 +1278,6 @@ async fn request_chatgpt_token_refresh( grant_type: "refresh_token", refresh_token, }; - let endpoint = refresh_token_endpoint(); // Use shared client factory to include standard headers @@ -1702,6 +1742,7 @@ pub struct AuthManager { refresh_lock: Semaphore, agent_identity_lock: Semaphore, external_auth: RwLock>>, + auth_route_config: Option, } /// Configuration view required to construct a shared [`AuthManager`]. @@ -1725,6 +1766,9 @@ pub trait AuthManagerConfig { /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; + + /// Returns route-selection settings for auth-owned clients. + fn auth_route_config(&self) -> Option; } impl Debug for AuthManager { @@ -1743,6 +1787,7 @@ impl Debug for AuthManager { &self.forced_chatgpt_workspace_id, ) .field("chatgpt_base_url", &self.chatgpt_base_url) + .field("auth_route_config", &self.auth_route_config) .field("has_external_auth", &self.has_external_auth()) .finish_non_exhaustive() } @@ -1764,6 +1809,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok(); @@ -1775,6 +1821,7 @@ impl AuthManager { chatgpt_base_url.as_deref(), keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config.as_ref(), ) .await .ok() @@ -1796,6 +1843,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config, } } @@ -1820,6 +1868,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1843,6 +1892,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1874,6 +1924,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1897,6 +1948,7 @@ impl AuthManager { external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc )), + auth_route_config: None, }) } @@ -1958,6 +2010,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await; @@ -1966,6 +2019,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await @@ -2074,6 +2128,7 @@ impl AuthManager { self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, self.agent_identity_authapi_base_url.as_deref(), + self.auth_route_config.as_ref(), ) .await .ok() @@ -2149,6 +2204,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Arc { Arc::new( Self::new( @@ -2158,6 +2214,7 @@ impl AuthManager { forced_chatgpt_workspace_id, chatgpt_base_url, keyring_backend_kind, + auth_route_config, ) .await, ) @@ -2175,6 +2232,7 @@ impl AuthManager { config.forced_chatgpt_workspace_id(), Some(config.chatgpt_base_url()), config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await } @@ -2328,7 +2386,9 @@ impl AuthManager { let auth_dot_json = self .auth_cached() .and_then(|auth| auth.get_current_auth_json()); - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = + revoke_auth_tokens(auth_dot_json.as_ref(), self.auth_route_config.as_ref()).await + { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } let result = logout_all_stores( diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index b99092f51f..ea75648d1b 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -5,7 +5,8 @@ use serde::Deserialize; use std::env; use std::fmt; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; @@ -13,7 +14,7 @@ const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] struct PersonalAccessTokenMetadata { - email: String, + email: Option, chatgpt_user_id: String, chatgpt_account_id: String, chatgpt_plan_type: String, @@ -36,13 +37,18 @@ impl fmt::Debug for PersonalAccessTokenAuth { } impl PersonalAccessTokenAuth { - pub(super) async fn load(access_token: &str) -> std::io::Result { + pub(super) async fn load( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) .ok() .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) .filter(|base_url| !base_url.is_empty()) .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); - hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + let endpoint = whoami_endpoint(&authapi_base_url); + let client = create_default_auth_client(&endpoint, auth_route_config)?; + hydrate_personal_access_token(&client, &endpoint, access_token).await } pub fn access_token(&self) -> &str { @@ -57,8 +63,8 @@ impl PersonalAccessTokenAuth { &self.metadata.chatgpt_user_id } - pub fn email(&self) -> &str { - &self.metadata.email + pub fn email(&self) -> Option<&str> { + self.metadata.email.as_deref() } pub fn plan_type(&self) -> AccountPlanType { @@ -72,12 +78,11 @@ impl PersonalAccessTokenAuth { async fn hydrate_personal_access_token( client: &CodexHttpClient, - authapi_base_url: &str, + endpoint: &str, access_token: &str, ) -> std::io::Result { - let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); let response = client - .get(&endpoint) + .get(endpoint) .bearer_auth(access_token) .send() .await @@ -107,6 +112,10 @@ async fn hydrate_personal_access_token( }) } +fn whoami_endpoint(authapi_base_url: &str) -> String { + format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')) +} + #[cfg(test)] #[path = "personal_access_token_tests.rs"] mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs index ac6ee12265..b05edb068b 100644 --- a/codex-rs/login/src/auth/personal_access_token_tests.rs +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::default_client::create_client; use pretty_assertions::assert_eq; use serde_json::json; use wiremock::Mock; @@ -29,7 +30,8 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { .mount(&server) .await; - let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await .expect("personal access token hydration should succeed"); @@ -38,7 +40,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { PersonalAccessTokenAuth { access_token: "at-example".to_string(), metadata: PersonalAccessTokenMetadata { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), chatgpt_user_id: "user-123".to_string(), chatgpt_account_id: "account-123".to_string(), chatgpt_plan_type: "enterprise".to_string(), @@ -50,7 +52,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { } #[tokio::test] -async fn hydrate_rejects_missing_email() { +async fn hydrate_preserves_missing_email() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path(WHOAMI_PATH)) @@ -59,13 +61,23 @@ async fn hydrate_rejects_missing_email() { .mount(&server) .await; - let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await - .expect_err("personal access token hydration should reject missing email"); + .expect("personal access token hydration should accept missing email"); - assert!( - err.to_string() - .contains("failed to decode personal access token metadata") + assert_eq!( + auth, + PersonalAccessTokenAuth { + access_token: "at-example".to_string(), + metadata: PersonalAccessTokenMetadata { + email: None, + chatgpt_user_id: "user-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_plan_type: "enterprise".to_string(), + chatgpt_account_is_fedramp: true, + }, + } ); server.verify().await; } diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 22de353d3a..0f7e313c1c 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -16,7 +16,8 @@ use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use super::manager::oauth_client_id; use super::storage::AuthDotJson; use super::util::try_parse_error_message; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); @@ -53,13 +54,14 @@ struct RevokeTokenRequest<'a> { pub(super) async fn revoke_auth_tokens( auth_dot_json: Option<&AuthDotJson>, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result<(), std::io::Error> { let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else { return Ok(()); }; - let client = create_client(); let endpoint = revoke_token_endpoint(); + let client = create_default_auth_client(&endpoint, auth_route_config)?; revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await } diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 19ba018f30..b5ae190551 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -92,13 +92,37 @@ pub struct AgentIdentityAuthRecord { pub agent_private_key: String, pub account_id: String, pub chatgpt_user_id: String, - pub email: String, + #[serde( + default, + deserialize_with = "deserialize_optional_non_empty_string", + serialize_with = "serialize_optional_string_as_empty" + )] + pub email: Option, pub plan_type: AccountPlanType, pub chatgpt_account_is_fedramp: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub task_id: Option, } +fn deserialize_optional_non_empty_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty())) +} + +fn serialize_optional_string_as_empty( + value: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + value.as_deref().unwrap_or_default().serialize(serializer) +} + impl AgentIdentityAuthRecord { pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result { let claims = diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index db51a7746e..647af79b65 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -102,7 +102,7 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, task_id: Some("task-id".to_string()), @@ -124,6 +124,85 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn file_storage_loads_empty_agent_identity_email_as_none() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write( + &auth_file, + serde_json::to_string_pretty(&json!({ + "auth_mode": "chatgpt", + "agent_identity": { + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }, + }))?, + )?; + + let loaded = storage.load()?; + + assert_eq!( + loaded, + Some(AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: None, + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: None, + })), + personal_access_token: None, + bedrock_api_key: None, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn file_storage_writes_missing_agent_identity_email_as_empty_string() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: None, + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: None, + })), + personal_access_token: None, + bedrock_api_key: None, + }; + + storage.save(&auth_dot_json)?; + + let auth_file = get_auth_file(codex_home.path()); + let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(auth_file)?)?; + assert_eq!(saved["agent_identity"]["email"], ""); + assert_eq!(storage.load()?, Some(auth_dot_json)); + Ok(()) +} + #[tokio::test] async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 6f18c5984c..70e6dff225 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -6,9 +6,9 @@ use serde::de::{self}; use std::time::Duration; use std::time::Instant; +use crate::default_client::build_raw_auth_reqwest_client; use crate::pkce::PkceCodes; use crate::server::ServerOptions; -use codex_client::build_reqwest_client_with_custom_ca; use std::io; const ANSI_BLUE: &str = "\x1b[94m"; @@ -157,8 +157,10 @@ fn print_device_code_prompt(verification_url: &str, code: &str) { } pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); + // The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint + // paths are not resolved separately. + let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; @@ -174,8 +176,8 @@ pub async fn complete_device_code_login( opts: ServerOptions, device_code: DeviceCode, ) -> std::io::Result<()> { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); + let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let code_resp = poll_for_token( @@ -199,6 +201,7 @@ pub async fn complete_device_code_login( &redirect_uri, &pkce, &code_resp.authorization_code, + opts.auth_route_config.as_ref(), ) .await .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 2b8e48b4a5..44c2f1c4d3 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -3,6 +3,7 @@ pub mod auth_env_telemetry; pub mod token_data; mod device_code_auth; +mod outbound_proxy; mod pkce; mod server; @@ -53,4 +54,5 @@ pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; pub use auth_env_telemetry::collect_auth_env_telemetry; +pub use outbound_proxy::AuthRouteConfig; pub use token_data::TokenData; diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs new file mode 100644 index 0000000000..bf7dfbeb4d --- /dev/null +++ b/codex-rs/login/src/outbound_proxy.rs @@ -0,0 +1,22 @@ +use codex_client::OutboundProxyConfig; + +/// Auth-layer adapter around client-owned proxy policy. +/// +/// `AuthConfig` carries this value while endpoint resolution and platform details remain in the +/// client layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthRouteConfig { + route_config: OutboundProxyConfig, +} + +impl AuthRouteConfig { + pub fn respect_system_proxy() -> Self { + Self { + route_config: OutboundProxyConfig::respect_system_proxy(), + } + } + + pub(crate) fn route_config(&self) -> &OutboundProxyConfig { + &self.route_config + } +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 67d5e8c663..c450da0f68 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -27,7 +27,9 @@ use std::time::Duration; use crate::auth::AuthDotJson; use crate::auth::AuthKeyringBackendKind; use crate::auth::save_auth; +use crate::default_client::build_raw_auth_reqwest_client; use crate::default_client::originator; +use crate::outbound_proxy::AuthRouteConfig; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use crate::token_data::TokenData; @@ -35,7 +37,6 @@ use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; -use codex_client::build_reqwest_client_with_custom_ca; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_template::Template; use rand::RngCore; @@ -71,6 +72,7 @@ pub struct ServerOptions { pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, + pub auth_route_config: Option, } impl ServerOptions { @@ -81,6 +83,7 @@ impl ServerOptions { forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { Self { codex_home, @@ -93,6 +96,7 @@ impl ServerOptions { codex_streamlined_login: false, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, } } } @@ -336,8 +340,15 @@ async fn process_request( } }; - match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code) - .await + match exchange_code_for_tokens( + &opts.issuer, + &opts.client_id, + redirect_uri, + pkce, + &code, + opts.auth_route_config.as_ref(), + ) + .await { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( @@ -353,9 +364,14 @@ async fn process_request( ); } // Obtain API key via token-exchange and persist - let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) - .await - .ok(); + let api_key = obtain_api_key( + &opts.issuer, + &opts.client_id, + &tokens.id_token, + opts.auth_route_config.as_ref(), + ) + .await + .ok(); if let Err(err) = persist_tokens_async( &opts.codex_home, api_key.clone(), @@ -719,6 +735,7 @@ pub(crate) async fn exchange_code_for_tokens( redirect_uri: &str, pkce: &PkceCodes, code: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { #[derive(serde::Deserialize)] struct TokenResponse { @@ -727,7 +744,9 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + // The route selected for the issuer is reused for token exchange; the token endpoint path is + // not resolved separately. + let client = build_raw_auth_reqwest_client(issuer.trim_end_matches('/'), auth_route_config)?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); info!( issuer = %sanitize_url_for_logging(issuer), @@ -1129,14 +1148,15 @@ pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, id_token: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { // Token exchange for an API key access token #[derive(serde::Deserialize)] struct ExchangeResp { access_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); + let client = build_raw_auth_reqwest_client(&token_endpoint, auth_route_config)?; let resp = client .post(token_endpoint) .header("Content-Type", "application/x-www-form-urlencoded") diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index af892d76a0..9c5eb936b1 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -1220,6 +1220,7 @@ impl RefreshTokenTestContext { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 78adc84c24..83cae8af5f 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -112,6 +112,7 @@ fn server_opts( /*forced_chatgpt_workspace_id*/ None, cli_auth_credentials_store_mode, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -277,6 +278,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -332,6 +334,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 3d7c5a180b..6f7b4e4f83 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -122,6 +122,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -185,6 +186,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -226,6 +228,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< let opts = ServerOptions { codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -268,6 +271,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -329,6 +333,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -398,6 +403,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -507,6 +513,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -545,6 +552,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let first_opts = ServerOptions { codex_home: first_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), port: 0, @@ -567,6 +575,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let second_opts = ServerOptions { codex_home: second_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: login_port, diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 3c47bc88b8..e8f9981a8a 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -61,6 +61,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -119,6 +120,7 @@ async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> R codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -161,6 +163,7 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -204,6 +207,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; save_auth( diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index d605b640ca..cbc113ff79 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -266,6 +266,7 @@ async fn run_codex_tool_session_inner( EventMsg::Warning(_) | EventMsg::GuardianWarning(_) | EventMsg::ModelVerification(_) + | EventMsg::SafetyBuffering(_) | EventMsg::TurnModerationMetadata(_) => { continue; } diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 026826b7c9..5b5ae81662 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -303,7 +303,7 @@ mod job { )?, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prompt.base_instructions = BaseInstructions { text: crate::stage_one::PROMPT.to_string(), @@ -429,7 +429,7 @@ mod job { role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } = item else { return should_persist_response_item_for_memories(item).then(|| item.clone()); @@ -457,7 +457,7 @@ mod job { role: role.clone(), content, phase: phase.clone(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }) } @@ -687,7 +687,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let skill_message = ResponseItem::Message { id: None, @@ -698,7 +698,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let subagent_message = ResponseItem::Message { id: None, @@ -708,7 +708,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let serialized = job::serialize_filtered_rollout_response_items(&[ @@ -730,7 +730,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, subagent_message, ] @@ -750,7 +750,7 @@ mod tests { ), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )]) .expect("serialize"); diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 13ba9da3b7..fad1ce14a4 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -685,7 +685,7 @@ async fn seed_stage1_candidate( text: "remember this startup test conversation".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }; let jsonl = serde_json::to_string(&line)?; diff --git a/codex-rs/model-provider/src/amazon_bedrock/error.rs b/codex-rs/model-provider/src/amazon_bedrock/error.rs new file mode 100644 index 0000000000..1e489352b9 --- /dev/null +++ b/codex-rs/model-provider/src/amazon_bedrock/error.rs @@ -0,0 +1,20 @@ +use codex_api::ApiError; +use codex_protocol::error::CodexErr; +use http::StatusCode; + +pub(super) const BEDROCK_EXPIRED_SIGNATURE_MESSAGE: &str = concat!( + "Amazon Bedrock rejected the request because its AWS signature has expired. ", + "Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, ", + "update or unset it, then restart Codex", +); + +pub(super) fn map_api_error(error: ApiError) -> CodexErr { + let mut error = codex_api::map_api_error(error); + if let CodexErr::UnexpectedStatus(response) = &mut error + && response.status == StatusCode::UNAUTHORIZED + && response.body.contains("Signature expired:") + { + response.user_message = Some(BEDROCK_EXPIRED_SIGNATURE_MESSAGE.to_string()); + } + error +} diff --git a/codex-rs/model-provider/src/amazon_bedrock/error_tests.rs b/codex-rs/model-provider/src/amazon_bedrock/error_tests.rs new file mode 100644 index 0000000000..50291c3eec --- /dev/null +++ b/codex-rs/model-provider/src/amazon_bedrock/error_tests.rs @@ -0,0 +1,77 @@ +use codex_api::ApiError; +use codex_api::TransportError; +use codex_protocol::error::CodexErr; +use http::HeaderMap; +use http::HeaderValue; +use http::StatusCode; +use pretty_assertions::assert_eq; + +use super::error::BEDROCK_EXPIRED_SIGNATURE_MESSAGE; +use super::error::map_api_error; + +const BEDROCK_RESPONSES_URL: &str = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses"; + +fn http_error(status: StatusCode, body: &str) -> ApiError { + let mut headers = HeaderMap::new(); + headers.insert("x-request-id", HeaderValue::from_static("req-bedrock")); + ApiError::Transport(TransportError::Http { + status, + url: Some(BEDROCK_RESPONSES_URL.to_string()), + headers: Some(headers), + body: Some(body.to_string()), + }) +} + +#[test] +fn expired_signature_has_actionable_guidance() { + let error = map_api_error(http_error( + StatusCode::UNAUTHORIZED, + "Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z", + )); + + let CodexErr::UnexpectedStatus(response) = &error else { + panic!("expected unexpected status error, got {error:?}"); + }; + assert_eq!( + response.user_message.as_deref(), + Some(BEDROCK_EXPIRED_SIGNATURE_MESSAGE) + ); + assert_eq!( + error.to_string(), + format!( + "{BEDROCK_EXPIRED_SIGNATURE_MESSAGE}, url: {BEDROCK_RESPONSES_URL}, request id: req-bedrock" + ) + ); +} + +#[test] +fn other_unauthorized_errors_remain_generic() { + let error = map_api_error(http_error( + StatusCode::UNAUTHORIZED, + "The security token included in the request is invalid", + )); + + let CodexErr::UnexpectedStatus(response) = &error else { + panic!("expected unexpected status error, got {error:?}"); + }; + assert_eq!(response.user_message, None); + assert_eq!( + error.to_string(), + format!( + "unexpected status 401 Unauthorized: The security token included in the request is invalid, url: {BEDROCK_RESPONSES_URL}, request id: req-bedrock" + ) + ); +} + +#[test] +fn signature_errors_with_other_statuses_remain_generic() { + let error = map_api_error(http_error( + StatusCode::FORBIDDEN, + "Signature expired: old is now earlier than new", + )); + + let CodexErr::UnexpectedStatus(response) = &error else { + panic!("expected unexpected status error, got {error:?}"); + }; + assert_eq!(response.user_message, None); +} diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 68c94fe4d5..108308c1bf 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -1,10 +1,12 @@ mod auth; mod catalog; +mod error; mod mantle; use std::path::PathBuf; use std::sync::Arc; +use codex_api::ApiError; use codex_api::Provider; use codex_api::SharedAuthProvider; use codex_login::AuthManager; @@ -17,6 +19,7 @@ use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::manager::StaticModelsManager; use codex_protocol::account::AmazonBedrockCredentialSource; use codex_protocol::account::ProviderAccount; +use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::openai_models::ModelsResponse; @@ -142,6 +145,10 @@ impl ModelProvider for AmazonBedrockModelProvider { }) } + fn map_api_error(&self, error: ApiError) -> CodexErr { + error::map_api_error(error) + } + fn api_provider(&self) -> ModelProviderFuture<'_, Result> { Box::pin(AmazonBedrockModelProvider::api_provider(self)) } @@ -166,6 +173,10 @@ impl ModelProvider for AmazonBedrockModelProvider { } } +#[cfg(test)] +#[path = "error_tests.rs"] +mod error_tests; + #[cfg(test)] mod tests { use http::HeaderValue; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 91a1c06a57..5df5a88ea1 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; +use codex_api::ApiError; use codex_api::Provider; use codex_api::SharedAuthProvider; use codex_login::AuthManager; @@ -13,6 +14,7 @@ use codex_models_manager::manager::OpenAiModelsManager; use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::manager::StaticModelsManager; use codex_protocol::account::ProviderAccount; +use codex_protocol::error::CodexErr; use codex_protocol::openai_models::ModelsResponse; use crate::amazon_bedrock::AmazonBedrockModelProvider; @@ -60,10 +62,7 @@ impl fmt::Display for ProviderAccountError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingChatgptAccountDetails => { - write!( - f, - "email and plan type are required for chatgpt authentication" - ) + write!(f, "plan type is required for chatgpt authentication") } Self::UnsupportedBedrockApiKeyAuth => { write!( @@ -145,6 +144,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { /// Returns the current app-visible account state for this provider. fn account_state(&self) -> ProviderAccountResult; + /// Maps an API client error into the provider's user-facing error representation. + fn map_api_error(&self, error: ApiError) -> CodexErr { + codex_api::map_api_error(error) + } + /// Returns provider configuration adapted for the API client. fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result> { Box::pin(async move { @@ -261,12 +265,9 @@ impl ModelProvider for ConfiguredModelProvider { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); - match (email, plan_type) { - (Some(email), Some(plan_type)) => { - Ok(ProviderAccount::Chatgpt { email, plan_type }) - } - _ => Err(ProviderAccountError::MissingChatgptAccountDetails), - } + plan_type + .map(|plan_type| ProviderAccount::Chatgpt { email, plan_type }) + .ok_or(ProviderAccountError::MissingChatgptAccountDetails) } }) .transpose()? @@ -313,6 +314,7 @@ mod tests { use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::WireApi; use codex_models_manager::manager::RefreshStrategy; + use codex_protocol::account::PlanType; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -518,7 +520,7 @@ mod tests { } #[test] - fn openai_provider_rejects_chatgpt_account_state_without_email() { + fn openai_provider_returns_chatgpt_account_state_without_email() { let provider = create_model_provider( ModelProviderInfo::create_openai_provider(/*base_url*/ None), Some(AuthManager::from_auth_for_testing( @@ -528,7 +530,13 @@ mod tests { assert_eq!( provider.account_state(), - Err(ProviderAccountError::MissingChatgptAccountDetails) + Ok(ProviderAccountState { + account: Some(ProviderAccount::Chatgpt { + email: None, + plan_type: PlanType::Unknown, + }), + requires_openai_auth: true, + }) ); } diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index f7006ca905..eb9c5a8932 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -232,6 +232,7 @@ c2ln", AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("auth should load") diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index cd32ff92b2..cc6a8de710 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -44,3 +44,9 @@ tempfile = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] rama-unix = { version = "=0.3.0-alpha.4" } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +[target.'cfg(windows)'.dependencies] +schannel = "0.1" diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 001469aa9a..2ff7cc03b4 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,6 +23,7 @@ use rama_tls_rustls::server::TlsAcceptorData; use sha2::Digest as _; use sha2::Sha256; use std::collections::HashMap; +use std::collections::HashSet; use std::fs; use std::fs::File; use std::fs::OpenOptions; @@ -30,6 +31,7 @@ use std::io::Write; use std::net::IpAddr; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tracing::info; @@ -101,6 +103,7 @@ const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; +pub(crate) const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; // Best-effort compatibility set for common child toolchains that accept a CA bundle path. // This is intentionally curated rather than pretending to cover every TLS client. @@ -117,6 +120,14 @@ pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ "NPM_CONFIG_CAFILE", ]; +pub(crate) fn ca_env_from_process() -> HashMap<&'static str, String> { + CUSTOM_CA_ENV_KEYS + .into_iter() + .chain([SSL_CERT_DIR_ENV_KEY]) + .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) + .collect() +} + /// Immutable managed MITM CA bundle path plus startup TLS env values. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ManagedMitmCaTrustBundle { @@ -146,15 +157,13 @@ fn managed_ca_trust_bundle_for_cert_path( cert_path: &Path, env: &HashMap<&'static str, String>, ) -> Result { - let startup_env_values = CUSTOM_CA_ENV_KEYS - .into_iter() - .filter_map(|key| { - env.get(key) - .filter(|value| !value.is_empty()) - .map(|value| (key, value.clone())) - }) - .collect(); - let trust_bundle = build_managed_ca_trust_bundle(cert_path)?; + let startup_env_values = startup_ca_file_env_values(env); + let startup_cert_dir = env + .get(SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty()) + .map(String::as_str); + let trust_bundle = + build_managed_ca_trust_bundle(cert_path, &startup_env_values, startup_cert_dir)?; let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?; Ok(ManagedMitmCaTrustBundle { @@ -163,21 +172,188 @@ fn managed_ca_trust_bundle_for_cert_path( }) } -fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { +pub(crate) fn upstream_tls_root_store( + env: &HashMap<&'static str, String>, +) -> Result> { + let (managed_ca_cert_path, _) = managed_ca_paths()?; + upstream_tls_root_store_for_cert_path(&managed_ca_cert_path, env) +} + +pub(crate) fn upstream_tls_root_store_for_cert_path( + managed_ca_cert_path: &Path, + env: &HashMap<&'static str, String>, +) -> Result> { + let startup_env_values = startup_ca_file_env_values(env); + let startup_cert_dir = env + .get(SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty()) + .map(String::as_str); + let certificates = load_platform_and_startup_root_certificates( + managed_ca_cert_path, + &startup_env_values, + startup_cert_dir, + )?; + let mut roots = rustls::RootCertStore::empty(); + let (_, ignored) = roots.add_parsable_certificates(certificates); + if ignored > 0 { + warn!( + ignored_root_count = ignored, + "ignored invalid platform or startup roots for MITM upstream TLS" + ); + } + Ok(Arc::new(roots)) +} + +fn startup_ca_file_env_values( + env: &HashMap<&'static str, String>, +) -> HashMap<&'static str, String> { + CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| { + env.get(key) + .filter(|value| !value.is_empty()) + .map(|value| (key, value.clone())) + }) + .collect() +} + +fn build_managed_ca_trust_bundle( + managed_ca_cert_path: &Path, + startup_env_values: &HashMap<&'static str, String>, + startup_cert_dir: Option<&str>, +) -> Result { let mut trust_bundle = String::new(); + for cert in load_platform_and_startup_root_certificates( + managed_ca_cert_path, + startup_env_values, + startup_cert_dir, + )? { + push_certificate_pem(&mut trust_bundle, cert.as_ref()); + } + append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; + Ok(trust_bundle) +} + +fn load_platform_and_startup_root_certificates( + managed_ca_cert_path: &Path, + startup_env_values: &HashMap<&'static str, String>, + startup_cert_dir: Option<&str>, +) -> Result>> { + let managed_ca_cert = fs::read(managed_ca_cert_path).with_context(|| { + format!( + "failed to read managed MITM CA certificate: {}", + managed_ca_cert_path.display() + ) + })?; + let managed_ca_cert = CertificateDer::from_pem_slice(&managed_ca_cert) + .context("failed to parse managed MITM CA certificate")?; let rustls_native_certs::CertificateResult { certs, errors, .. } = - rustls_native_certs::load_native_certs(); + crate::native_certs::load_platform_native_certs(); if !errors.is_empty() { warn!( native_root_error_count = errors.len(), "encountered errors while loading native root certificates for MITM trust bundle" ); } - for cert in certs { - push_certificate_pem(&mut trust_bundle, cert.as_ref()); + let mut certificates = certs; + let mut appended_startup_paths = HashSet::new(); + for path in CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| startup_env_values.get(key)) + .map(PathBuf::from) + { + if path != managed_ca_cert_path + && !is_current_generated_trust_bundle_path(&path, managed_ca_cert_path) + && appended_startup_paths.insert(path.clone()) + { + certificates.extend(read_ca_certificates(&path)?); + } } - append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; - Ok(trust_bundle) + if let Some(startup_cert_dir) = startup_cert_dir { + for path in std::env::split_paths(startup_cert_dir) { + if appended_startup_paths.insert(path.clone()) { + certificates.extend(load_ca_directory_certificates(&path)); + } + } + } + let mut seen = HashSet::new(); + certificates.retain(|cert| cert != &managed_ca_cert && seen.insert(cert.as_ref().to_vec())); + Ok(certificates) +} + +fn read_ca_certificates(path: &Path) -> Result>> { + let pem = fs::read(path) + .with_context(|| format!("failed to read startup CA bundle: {}", path.display()))?; + let pem = String::from_utf8_lossy(&pem); + let contains_trusted_certificates = pem.contains("TRUSTED CERTIFICATE"); + let normalized_pem = pem + .replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE") + .replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"); + let certs = CertificateDer::pem_slice_iter(normalized_pem.as_bytes()) + .collect::, _>>() + .with_context(|| format!("failed to parse startup CA bundle: {}", path.display()))?; + if certs.is_empty() { + return Err(anyhow!( + "startup CA bundle contained no certificates: {}", + path.display() + )); + } + certs + .into_iter() + .map(|cert| { + let cert = if contains_trusted_certificates { + first_der_item(cert.as_ref()).ok_or_else(|| { + anyhow!( + "startup CA bundle contained an invalid trusted certificate: {}", + path.display() + ) + })? + } else { + cert.as_ref() + }; + Ok(CertificateDer::from(cert.to_vec())) + }) + .collect() +} + +fn load_ca_directory_certificates(path: &Path) -> Vec> { + let rustls_native_certs::CertificateResult { certs, errors, .. } = + rustls_native_certs::load_certs_from_paths(None, Some(path)); + if !errors.is_empty() { + warn!( + ca_path = %path.display(), + ca_error_count = errors.len(), + "encountered errors while loading startup CA directory" + ); + } + certs +} + +fn first_der_item(der: &[u8]) -> Option<&[u8]> { + der_item_length(der).map(|length| &der[..length]) +} + +fn der_item_length(der: &[u8]) -> Option { + let &length_octet = der.get(1)?; + if length_octet & 0x80 == 0 { + return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len()); + } + + let length_octets = usize::from(length_octet & 0x7f); + if length_octets == 0 { + return None; + } + + let length_end = 2usize.checked_add(length_octets)?; + let mut content_length = 0usize; + for &byte in der.get(2..length_end)? { + content_length = content_length + .checked_mul(256)? + .checked_add(usize::from(byte))?; + } + length_end + .checked_add(content_length) + .filter(|length| *length <= der.len()) } fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Path) -> bool { @@ -508,17 +684,80 @@ mod tests { } #[test] - fn managed_ca_trust_bundle_records_startup_ca_env_values() { + fn managed_ca_trust_bundle_appends_startup_file_and_directory_certificates() { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); - fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); - let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + let startup_ca_dir = dir.path().join("startup-certs"); + let (managed_ca_cert, _) = generate_ca().unwrap(); + let (startup_ca_cert, startup_ca_key) = generate_ca().unwrap(); + let (directory_ca_cert, _) = generate_ca().unwrap(); + let mut trusted_ca_der = CertificateDer::from_pem_slice(startup_ca_cert.as_bytes()) + .unwrap() + .as_ref() + .to_vec(); + trusted_ca_der.extend_from_slice(&[0x30, 0x00]); + let mut trusted_ca_cert = String::new(); + push_certificate_pem(&mut trusted_ca_cert, &trusted_ca_der); + let trusted_ca_cert = trusted_ca_cert.replace("CERTIFICATE", "TRUSTED CERTIFICATE"); + fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap(); + fs::write( + &startup_ca_bundle_path, + format!("{trusted_ca_cert}{startup_ca_key}"), + ) + .unwrap(); + fs::create_dir(&startup_ca_dir).unwrap(); + fs::write(startup_ca_dir.join("directory-ca.pem"), &directory_ca_cert).unwrap(); + let startup_ca_bundle_path = startup_ca_bundle_path.display().to_string(); + let env = HashMap::from([ + ("SSL_CERT_FILE", startup_ca_bundle_path.clone()), + (SSL_CERT_DIR_ENV_KEY, startup_ca_dir.display().to_string()), + ]); + let trust_bundle = managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); assert_eq!( trust_bundle.startup_env_values, - HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + HashMap::from([("SSL_CERT_FILE", startup_ca_bundle_path)]) ); + let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap(); + let baseline_certs = CertificateDer::pem_slice_iter(baseline_bundle.as_bytes()) + .collect::, _>>() + .unwrap(); + let expected_certs = [&startup_ca_cert, &directory_ca_cert, &managed_ca_cert] + .map(|cert| CertificateDer::from_pem_slice(cert.as_bytes()).unwrap()); + + assert!( + expected_certs + .iter() + .all(|cert| baseline_certs.contains(cert)) + ); + assert!(!baseline_bundle.contains(&startup_ca_key)); + assert!(!baseline_bundle.contains("TRUSTED CERTIFICATE")); + } + + #[test] + fn managed_ca_trust_bundle_skips_inherited_current_bundle() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let inherited_bundle_path = dir.path().join("ca-bundle-parent.pem"); + let (managed_ca_cert, _) = generate_ca().unwrap(); + fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap(); + fs::write( + &inherited_bundle_path, + format!("parent roots\n{managed_ca_cert}"), + ) + .unwrap(); + let env = HashMap::from([( + "REQUESTS_CA_BUNDLE", + inherited_bundle_path.display().to_string(), + )]); + + let trust_bundle = + managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); + let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap(); + + assert_eq!(baseline_bundle.matches(&managed_ca_cert).count(), 1); } #[cfg(unix)] diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index e80b154a5e..c8950f1f55 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -6,6 +6,7 @@ mod connect_policy; mod http_proxy; mod mitm; mod mitm_hook; +mod native_certs; mod network_policy; mod policy; mod proxy; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 345c5b5032..70175ca706 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -107,11 +107,19 @@ impl MitmState { // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and // apply policy. let ca = ManagedMitmCa::load_or_create()?; + let upstream_tls_root_store = + crate::certs::upstream_tls_root_store(&crate::certs::ca_env_from_process())?; let upstream = if config.allow_upstream_proxy { - UpstreamClient::from_env_proxy_with_allow_local_binding(config.allow_local_binding) + UpstreamClient::from_env_proxy_with_allow_local_binding( + config.allow_local_binding, + upstream_tls_root_store, + ) } else { - UpstreamClient::direct_with_allow_local_binding(config.allow_local_binding) + UpstreamClient::direct_with_allow_local_binding( + config.allow_local_binding, + upstream_tls_root_store, + ) }; Ok(Self { diff --git a/codex-rs/network-proxy/src/native_certs.rs b/codex-rs/network-proxy/src/native_certs.rs new file mode 100644 index 0000000000..6c8d45005d --- /dev/null +++ b/codex-rs/network-proxy/src/native_certs.rs @@ -0,0 +1,260 @@ +#[cfg(any(target_os = "macos", windows))] +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rustls_native_certs::CertificateResult; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::Error; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::ErrorKind; + +// `rustls_native_certs::load_native_certs()` first consults SSL_CERT_FILE and +// SSL_CERT_DIR. Load platform roots directly so a startup custom CA can be +// layered onto the managed bundle without replacing the platform trust store. +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + let mut result = + rustls_native_certs::load_certs_from_paths(platform_cert_file().as_deref(), None); + for cert_dir in platform_cert_dirs() { + extend_certificate_result( + &mut result, + rustls_native_certs::load_certs_from_paths(None, Some(&cert_dir)), + ); + } + dedupe_certs(&mut result); + result +} + +#[cfg(target_os = "macos")] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use security_framework::trust_settings::Domain; + use security_framework::trust_settings::TrustSettings; + use security_framework::trust_settings::TrustSettingsForCertificate; + use std::collections::BTreeMap; + + let mut result = CertificateResult::default(); + let mut all_certs = BTreeMap::new(); + for domain in &[Domain::User, Domain::Admin, Domain::System] { + let ts = TrustSettings::new(*domain); + let iter = match ts.iter() { + Ok(iter) => iter, + Err(err) => { + result.errors.push(Error { + context: match domain { + Domain::User => "failed to load user trust settings", + Domain::Admin => "failed to load admin trust settings", + Domain::System => "failed to load system trust settings", + }, + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + + for cert in iter { + let der = cert.to_der(); + let trusted = match ts.tls_trust_settings_for_certificate(&cert) { + Ok(trusted) => trusted.unwrap_or(TrustSettingsForCertificate::TrustRoot), + Err(err) => { + result.errors.push(Error { + context: "certificate not trusted", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + all_certs.entry(der).or_insert(trusted); + } + } + + for (der, trusted) in all_certs { + use TrustSettingsForCertificate::*; + + if let TrustRoot | TrustAsRoot = trusted { + result.certs.push(CertificateDer::from(der)); + } + } + result +} + +#[cfg(windows)] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use schannel::cert_store::CertStore; + + let mut result = CertificateResult::default(); + let current_user_store = match CertStore::open_current_user("ROOT") { + Ok(store) => store, + Err(err) => { + result.errors.push(Error { + context: "failed to open current user certificate store", + kind: ErrorKind::Os(err.into()), + }); + return result; + } + }; + + for cert in current_user_store.certs() { + let valid_uses = match cert.valid_uses() { + Ok(valid_uses) => valid_uses, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate valid uses", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + let is_time_valid = match cert.is_time_valid() { + Ok(is_time_valid) => is_time_valid, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate time validity", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + if usable_for_rustls(valid_uses) && is_time_valid { + result + .certs + .push(CertificateDer::from(cert.to_der().to_vec())); + } + } + result +} + +#[cfg(not(any(all(unix, not(target_os = "macos")), target_os = "macos", windows)))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + rustls_native_certs::load_native_certs() +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extend_certificate_result(result: &mut CertificateResult, extra: CertificateResult) { + result.certs.extend(extra.certs); + result.errors.extend(extra.errors); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn dedupe_certs(result: &mut CertificateResult) { + result.certs.sort_unstable_by(|a, b| a.cmp(b)); + result.certs.dedup(); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_file() -> Option { + PLATFORM_CERTIFICATE_FILE_NAMES + .iter() + .map(std::path::Path::new) + .find(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_dirs() -> impl Iterator { + PLATFORM_CERTIFICATE_DIRS + .iter() + .map(std::path::Path::new) + .filter(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &[ + "/etc/ssl/certs", + "/etc/pki/tls/certs", + "/etc/security/certificates", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs", "/usr/local/share/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + any(target_os = "illumos", target_os = "solaris") +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/certs/CA"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/openssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "aix"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/var/ssl/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "illumos", + target_os = "solaris", + target_os = "netbsd", + target_os = "aix" + )) +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/ssl/cert.pem", + "/opt/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/certs/cacert.pem", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "dragonfly"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/share/certs/ca-root-nss.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/openssl/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "openbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "solaris"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "illumos"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/etc/ssl/cacert.pem", "/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "android"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/data/data/com.termux/files/usr/etc/tls/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "haiku"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/boot/system/data/ssl/CARootCertificates.pem"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "android", + target_os = "haiku", + )) +))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/certs/ca-certificates.crt"]; + +#[cfg(windows)] +fn usable_for_rustls(uses: schannel::cert_context::ValidUses) -> bool { + match uses { + schannel::cert_context::ValidUses::All => true, + schannel::cert_context::ValidUses::Oids(strs) => strs.iter().any(|x| x == PKIX_SERVER_AUTH), + } +} + +#[cfg(windows)] +const PKIX_SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1"; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ef56846599..13998dd447 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -308,10 +308,7 @@ struct NetworkProxyRuntimeSettings { impl NetworkProxyRuntimeSettings { fn from_config(config: &config::NetworkProxyConfig) -> Result { let mitm_ca_trust_bundle = if config.network.mitm { - let env = crate::certs::CUSTOM_CA_ENV_KEYS - .into_iter() - .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) - .collect(); + let env = crate::certs::ca_env_from_process(); Some(crate::certs::managed_ca_trust_bundle(&env)?) } else { None diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index 3437b0d32d..5d36706ffb 100644 --- a/codex-rs/network-proxy/src/upstream.rs +++ b/codex-rs/network-proxy/src/upstream.rs @@ -21,6 +21,8 @@ use rama_net::client::EstablishedClientConnection; use rama_net::http::RequestContext; use rama_tls_rustls::client::TlsConnectorDataBuilder; use rama_tls_rustls::client::TlsConnectorLayer; +use rama_tls_rustls::client::client_root_certs; +use rama_tls_rustls::dep::rustls; use std::sync::Arc; use std::time::Instant; use tracing::info; @@ -104,6 +106,7 @@ impl UpstreamClient { Self::new( ProxyConfig::default(), TargetCheckedTcpConnector::new(state), + client_root_certs(), ) } @@ -111,20 +114,29 @@ impl UpstreamClient { Self::new( ProxyConfig::from_env(), TargetCheckedTcpConnector::new(state), + client_root_certs(), ) } - pub(crate) fn direct_with_allow_local_binding(allow_local_binding: bool) -> Self { + pub(crate) fn direct_with_allow_local_binding( + allow_local_binding: bool, + tls_root_store: Arc, + ) -> Self { Self::new( ProxyConfig::default(), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), + tls_root_store, ) } - pub(crate) fn from_env_proxy_with_allow_local_binding(allow_local_binding: bool) -> Self { + pub(crate) fn from_env_proxy_with_allow_local_binding( + allow_local_binding: bool, + tls_root_store: Arc, + ) -> Self { Self::new( ProxyConfig::from_env(), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), + tls_root_store, ) } @@ -137,8 +149,12 @@ impl UpstreamClient { } } - fn new(proxy_config: ProxyConfig, transport: TargetCheckedTcpConnector) -> Self { - let connector = build_http_connector(transport); + fn new( + proxy_config: ProxyConfig, + transport: TargetCheckedTcpConnector, + tls_root_store: Arc, + ) -> Self { + let connector = build_http_connector(transport, tls_root_store); Self { connector, proxy_config, @@ -221,6 +237,7 @@ impl Service> for UpstreamClient { fn build_http_connector( transport: TargetCheckedTcpConnector, + tls_root_store: Arc, ) -> BoxService< Request, EstablishedClientConnection, Request>, @@ -228,7 +245,10 @@ fn build_http_connector( > { ensure_rustls_crypto_provider(); let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); - let tls_config = TlsConnectorDataBuilder::new() + let client_config = rustls::ClientConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS) + .with_root_certificates(tls_root_store) + .with_no_client_auth(); + let tls_config = TlsConnectorDataBuilder::from(client_config) .with_alpn_protocols_http_auto() .build(); let tls = TlsConnectorLayer::auto() @@ -239,6 +259,10 @@ fn build_http_connector( connector.boxed() } +#[cfg(test)] +#[path = "upstream_tests.rs"] +mod tests; + #[cfg(target_os = "macos")] fn build_unix_connector( path: &str, diff --git a/codex-rs/network-proxy/src/upstream_tests.rs b/codex-rs/network-proxy/src/upstream_tests.rs new file mode 100644 index 0000000000..85f5da9017 --- /dev/null +++ b/codex-rs/network-proxy/src/upstream_tests.rs @@ -0,0 +1,111 @@ +use super::*; +use pretty_assertions::assert_eq; +use rama_http::StatusCode; +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rama_tls_rustls::dep::pki_types::PrivateKeyDer; +use rama_tls_rustls::dep::pki_types::pem::PemObject; +use rama_tls_rustls::dep::rcgen::BasicConstraints; +use rama_tls_rustls::dep::rcgen::CertificateParams; +use rama_tls_rustls::dep::rcgen::DistinguishedName; +use rama_tls_rustls::dep::rcgen::DnType; +use rama_tls_rustls::dep::rcgen::ExtendedKeyUsagePurpose; +use rama_tls_rustls::dep::rcgen::IsCa; +use rama_tls_rustls::dep::rcgen::Issuer; +use rama_tls_rustls::dep::rcgen::KeyPair; +use rama_tls_rustls::dep::rcgen::KeyUsagePurpose; +use rama_tls_rustls::dep::rcgen::PKCS_ECDSA_P256_SHA256; +use rama_tls_rustls::dep::tokio_rustls::TlsAcceptor; +use std::collections::HashMap; +use std::fs; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; + +fn generate_ca(common_name: &str) -> (String, KeyPair) { + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let mut distinguished_name = DistinguishedName::new(); + distinguished_name.push(DnType::CommonName, common_name); + params.distinguished_name = distinguished_name; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + (cert.pem(), key_pair) +} + +#[tokio::test] +async fn mitm_upstream_client_trusts_startup_custom_ca() { + ensure_rustls_crypto_provider(); + let temp_dir = tempdir().unwrap(); + let startup_ca_path = temp_dir.path().join("startup-ca.pem"); + let managed_ca_path = temp_dir.path().join("managed-ca.pem"); + let (startup_ca_pem, startup_ca_key) = generate_ca("startup CA"); + let (managed_ca_pem, _) = generate_ca("managed MITM CA"); + fs::write(&startup_ca_path, &startup_ca_pem).unwrap(); + fs::write(&managed_ca_path, managed_ca_pem).unwrap(); + + let issuer = Issuer::from_ca_cert_pem(&startup_ca_pem, startup_ca_key).unwrap(); + let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + server_params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let server_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let server_cert = server_params.signed_by(&server_key, &issuer).unwrap(); + let server_cert = CertificateDer::from_pem_slice(server_cert.pem().as_bytes()).unwrap(); + let server_key = PrivateKeyDer::from_pem_slice(server_key.serialize_pem().as_bytes()).unwrap(); + let mut server_config = + rustls::ServerConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS) + .with_no_client_auth() + .with_single_cert(vec![server_cert], server_key) + .unwrap(); + server_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + let env = HashMap::from([( + "SSL_CERT_FILE", + startup_ca_path.to_string_lossy().into_owned(), + )]); + let roots = + crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &env).unwrap(); + let baseline_roots = + crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &HashMap::new()) + .unwrap(); + assert_eq!(roots.len(), baseline_roots.len() + 1); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut stream = acceptor.accept(stream).await.unwrap(); + let mut request = [0; 4096]; + let bytes_read = stream.read(&mut request).await.unwrap(); + assert!(bytes_read > 0); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + }); + + let client = + UpstreamClient::direct_with_allow_local_binding(/*allow_local_binding*/ true, roots); + let response = client + .serve( + Request::builder() + .uri(format!("https://localhost:{}/", address.port())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + server.await.unwrap(); +} diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 8f0471088f..7e2700236b 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -94,6 +94,8 @@ pub struct SessionTelemetryMetadata { pub(crate) session_source: String, pub(crate) model: String, pub(crate) slug: String, + pub(crate) service_tier: Option, + pub(crate) model_reasoning_effort: Option, pub(crate) log_user_prompts: bool, pub(crate) app_version: &'static str, pub(crate) terminal_type: String, @@ -118,6 +120,16 @@ impl SessionTelemetry { self } + pub fn with_inference_request( + mut self, + service_tier: Option<&str>, + model_reasoning_effort: Option<&ReasoningEffort>, + ) -> Self { + self.metadata.service_tier = service_tier.map(str::to_owned); + self.metadata.model_reasoning_effort = model_reasoning_effort.map(ToString::to_string); + self + } + pub fn with_metrics_service_name(mut self, service_name: &str) -> Self { self.metadata.service_name = Some(sanitize_metric_tag_value(service_name)); self @@ -389,6 +401,8 @@ impl SessionTelemetry { session_source: session_source.to_string(), model: model.to_owned(), slug: slug.to_owned(), + service_tier: None, + model_reasoning_effort: None, log_user_prompts, app_version: env!("CARGO_PKG_VERSION"), terminal_type, @@ -707,7 +721,6 @@ impl SessionTelemetry { duration: Duration, ) { let mut kind = None; - let mut error_message = None; let mut success = true; match result { @@ -724,49 +737,26 @@ impl SessionTelemetry { } if kind.as_deref() == Some("response.failed") { success = false; - error_message = value - .get("response") - .and_then(|value| value.get("error")) - .map(serde_json::Value::to_string) - .or_else(|| Some("response.failed event received".to_string())); } } - Err(err) => { + Err(_) => { kind = Some("parse_error".to_string()); - error_message = Some(err.to_string()); success = false; } } } - tokio_tungstenite::tungstenite::Message::Binary(_) => { - success = false; - error_message = Some("unexpected binary websocket event".to_string()); - } tokio_tungstenite::tungstenite::Message::Ping(_) | tokio_tungstenite::tungstenite::Message::Pong(_) => { return; } - tokio_tungstenite::tungstenite::Message::Close(_) => { + tokio_tungstenite::tungstenite::Message::Binary(_) + | tokio_tungstenite::tungstenite::Message::Close(_) + | tokio_tungstenite::tungstenite::Message::Frame(_) => { success = false; - error_message = - Some("websocket closed by server before response.completed".to_string()); - } - tokio_tungstenite::tungstenite::Message::Frame(_) => { - success = false; - error_message = Some("unexpected websocket frame".to_string()); } }, - Ok(Some(Err(err))) => { + Ok(Some(Err(_))) | Ok(None) | Err(_) => { success = false; - error_message = Some(err.to_string()); - } - Ok(None) => { - success = false; - error_message = Some("stream closed before response.completed".to_string()); - } - Err(err) => { - success = false; - error_message = Some(err.to_string()); } } @@ -775,18 +765,6 @@ impl SessionTelemetry { let tags = [("kind", kind_str), ("success", success_str)]; self.counter(WEBSOCKET_EVENT_COUNT_METRIC, /*inc*/ 1, &tags); self.record_duration(WEBSOCKET_EVENT_DURATION_METRIC, duration, &tags); - log_and_trace_event!( - self, - common: { - event.name = "codex.websocket_event", - event.kind = %kind_str, - duration_ms = %duration.as_millis(), - success = success_str, - error.message = error_message.as_deref(), - }, - log: {}, - trace: {}, - ); } pub fn log_sse_event( @@ -932,6 +910,8 @@ impl SessionTelemetry { cached_token_count = cached_token_count, reasoning_token_count = reasoning_token_count, tool_token_count = %tool_token_count, + service_tier = self.metadata.service_tier.as_deref(), + model_reasoning_effort = self.metadata.model_reasoning_effort.as_deref(), }, log: {}, trace: {}, @@ -1209,6 +1189,7 @@ impl SessionTelemetry { ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ModelVerifications(_) => "model_verifications".into(), ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(), + ResponseEvent::SafetyBuffering(_) => "safety_buffering".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), @@ -1217,6 +1198,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { + ResponseItem::AdditionalTools { .. } => "additional_tools".into(), ResponseItem::Message { role, .. } => format!("message_from_{role}"), ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 7daa78bc06..1a4eb23af1 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -76,25 +76,3 @@ pub struct PluginTelemetryMetadata { pub remote_plugin_id: Option, pub capability_summary: Option, } - -impl PluginTelemetryMetadata { - pub fn from_plugin_id(plugin_id: &PluginId) -> Self { - Self { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: None, - } - } -} - -impl PluginCapabilitySummary { - pub fn telemetry_metadata(&self) -> Option { - PluginId::parse(&self.config_name) - .ok() - .map(|plugin_id| PluginTelemetryMetadata { - plugin_id, - remote_plugin_id: None, - capability_summary: Some(self.clone()), - }) - } -} diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 599f4ee407..eb8e1f7d93 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -53,6 +53,7 @@ pub struct PluginManifestInterface { pub brand_color: Option, pub composer_icon: Option, pub logo: Option, + pub logo_dark: Option, pub screenshots: Vec, } @@ -72,6 +73,7 @@ impl Default for PluginManifestInterface { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), } } @@ -141,6 +143,7 @@ impl PluginManifest { brand_color, composer_icon, logo, + logo_dark, screenshots, } = interface; Some(PluginManifestInterface { @@ -157,6 +160,7 @@ impl PluginManifest { brand_color, composer_icon: composer_icon.map(&mut map).transpose()?, logo: logo.map(&mut map).transpose()?, + logo_dark: logo_dark.map(&mut map).transpose()?, screenshots: screenshots .into_iter() .map(&mut map) diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index 11286eddc8..dfcac51d30 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -35,7 +35,7 @@ pub enum PlanType { pub enum ProviderAccount { ApiKey, Chatgpt { - email: String, + email: Option, plan_type: PlanType, }, AmazonBedrock { diff --git a/codex-rs/protocol/src/compacted_item.rs b/codex-rs/protocol/src/compacted_item.rs index 3415373898..0f437e0bb6 100644 --- a/codex-rs/protocol/src/compacted_item.rs +++ b/codex-rs/protocol/src/compacted_item.rs @@ -23,6 +23,8 @@ impl<'de> Deserialize<'de> for CompactedItem { message: serialized.message, replacement_history: serialized.replacement_history, window_number, + first_window_id: serialized.first_window_id, + previous_window_id: serialized.previous_window_id, window_id, }) } @@ -36,6 +38,10 @@ struct SerializedCompactedItem { #[serde(default)] window_number: Option, #[serde(default)] + first_window_id: Option, + #[serde(default)] + previous_window_id: Option, + #[serde(default)] window_id: Option, } @@ -59,6 +65,8 @@ mod tests { message: "summary".to_string(), replacement_history: None, window_number: Some(3), + first_window_id: Some("019b3f6e-0000-7000-8000-000000000001".to_string()), + previous_window_id: Some("019b3f6e-0000-7000-8000-000000000002".to_string()), window_id: Some("019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001".to_string()), }; @@ -67,6 +75,8 @@ mod tests { json!({ "message": "summary", "window_number": 3, + "first_window_id": "019b3f6e-0000-7000-8000-000000000001", + "previous_window_id": "019b3f6e-0000-7000-8000-000000000002", "window_id": "019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001", }) ); @@ -86,6 +96,8 @@ mod tests { message: "summary".to_string(), replacement_history: None, window_number: Some(3), + first_window_id: None, + previous_window_id: None, window_id: None, } ); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 5c250648a9..83bcbdc0ae 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -296,8 +296,10 @@ pub enum Personality { Pragmatic, } -/// Controls whether the model should only spawn sub-agents after an explicit -/// user request or may delegate proactively when doing so would help. +/// Controls whether the model receives multi-agent delegation instructions and, +/// when it does, whether it should only spawn sub-agents after an explicit user +/// request or may delegate proactively when doing so would help. `none` leaves +/// the multi-agent tools available without injecting delegation instructions. #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] @@ -305,6 +307,7 @@ pub enum Personality { #[ts(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum MultiAgentMode { + None, #[default] ExplicitRequestOnly, Proactive, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index d7e953af0b..a2ea0eb691 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -305,6 +305,7 @@ impl std::fmt::Display for ResponseStreamFailed { pub struct UnexpectedResponseError { pub status: StatusCode, pub body: String, + pub user_message: Option, pub url: Option, pub cf_ray: Option, pub request_id: Option, @@ -312,8 +313,6 @@ pub struct UnexpectedResponseError { pub identity_error_code: Option, } -const CLOUDFLARE_BLOCKED_MESSAGE: &str = - "Access blocked by Cloudflare. This usually happens when connecting from a restricted region"; const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000; impl UnexpectedResponseError { @@ -343,18 +342,17 @@ impl UnexpectedResponseError { Some(message.to_string()) } } +} - fn friendly_message(&self) -> Option { - if self.status != StatusCode::FORBIDDEN { - return None; - } - - if !self.body.contains("Cloudflare") || !self.body.contains("blocked") { - return None; - } - - let status = self.status; - let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})"); +impl std::fmt::Display for UnexpectedResponseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut message = if let Some(user_message) = &self.user_message { + user_message.clone() + } else { + let status = self.status; + let body = self.display_body(); + format!("unexpected status {status}: {body}") + }; if let Some(url) = &self.url { message.push_str(&format!(", url: {url}")); } @@ -370,36 +368,7 @@ impl UnexpectedResponseError { if let Some(error_code) = &self.identity_error_code { message.push_str(&format!(", auth error code: {error_code}")); } - - Some(message) - } -} - -impl std::fmt::Display for UnexpectedResponseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(friendly) = self.friendly_message() { - write!(f, "{friendly}") - } else { - let status = self.status; - let body = self.display_body(); - let mut message = format!("unexpected status {status}: {body}"); - if let Some(url) = &self.url { - message.push_str(&format!(", url: {url}")); - } - if let Some(cf_ray) = &self.cf_ray { - message.push_str(&format!(", cf-ray: {cf_ray}")); - } - if let Some(id) = &self.request_id { - message.push_str(&format!(", request id: {id}")); - } - if let Some(auth_error) = &self.identity_authorization_error { - message.push_str(&format!(", auth error: {auth_error}")); - } - if let Some(error_code) = &self.identity_error_code { - message.push_str(&format!(", auth error code: {error_code}")); - } - write!(f, "{message}") - } + write!(f, "{message}") } } diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index bd7a5f0058..0adbd029cd 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -400,31 +400,12 @@ fn usage_limit_reached_includes_minutes_when_available() { }); } -#[test] -fn unexpected_status_cloudflare_html_is_simplified() { - let err = UnexpectedResponseError { - status: StatusCode::FORBIDDEN, - body: "Cloudflare error: Sorry, you have been blocked" - .to_string(), - url: Some("http://example.com/blocked".to_string()), - cf_ray: Some("ray-id".to_string()), - request_id: None, - identity_authorization_error: None, - identity_error_code: None, - }; - let status = StatusCode::FORBIDDEN.to_string(); - let url = "http://example.com/blocked"; - assert_eq!( - err.to_string(), - format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id") - ); -} - #[test] fn unexpected_status_non_html_is_unchanged() { let err = UnexpectedResponseError { status: StatusCode::FORBIDDEN, body: "plain text error".to_string(), + user_message: None, url: Some("http://example.com/plain".to_string()), cf_ray: None, request_id: None, @@ -439,12 +420,32 @@ fn unexpected_status_non_html_is_unchanged() { ); } +#[test] +fn unexpected_status_uses_user_message_and_preserves_response_context() { + let err = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "provider-specific response".to_string(), + user_message: Some("Provider-specific guidance".to_string()), + url: Some("https://example.com/v1/responses".to_string()), + cf_ray: None, + request_id: Some("req-provider".to_string()), + identity_authorization_error: None, + identity_error_code: None, + }; + + assert_eq!( + err.to_string(), + "Provider-specific guidance, url: https://example.com/v1/responses, request id: req-provider" + ); +} + #[test] fn unexpected_status_prefers_error_message_when_present() { let err = UnexpectedResponseError { status: StatusCode::UNAUTHORIZED, body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"# .to_string(), + user_message: None, url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), cf_ray: None, request_id: Some("req-123".to_string()), @@ -466,6 +467,7 @@ fn unexpected_status_truncates_long_body_with_ellipsis() { let err = UnexpectedResponseError { status: StatusCode::BAD_GATEWAY, body: long_body, + user_message: None, url: Some("http://example.com/long".to_string()), cf_ray: None, request_id: Some("req-long".to_string()), @@ -487,6 +489,7 @@ fn unexpected_status_includes_cf_ray_and_request_id() { let err = UnexpectedResponseError { status: StatusCode::UNAUTHORIZED, body: "plain text error".to_string(), + user_message: None, url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()), request_id: Some("req-xyz".to_string()), @@ -507,6 +510,7 @@ fn unexpected_status_includes_identity_auth_details() { let err = UnexpectedResponseError { status: StatusCode::UNAUTHORIZED, body: "plain text error".to_string(), + user_message: None, url: Some("https://chatgpt.com/backend-api/codex/models".to_string()), cf_ray: Some("cf-ray-auth-401-test".to_string()), request_id: Some("req-auth".to_string()), diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 08d257dc3b..76bc0e3992 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -404,7 +404,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub source_call_id: Option, +} + +impl InternalChatMessageMetadataPassthrough { + pub(crate) fn set_turn_id_if_missing(metadata: &mut Option, turn_id: &str) { + if turn_id.is_empty() + || metadata + .as_ref() + .and_then(|metadata| metadata.turn_id.as_deref()) + .is_some_and(|turn_id| !turn_id.is_empty()) + { + return; + } + metadata.get_or_insert_with(Self::default).turn_id = Some(turn_id.to_string()); + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { + #[schemars(skip)] + #[ts(skip)] + AdditionalTools { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + role: String, + tools: Vec, + }, Message { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -931,7 +954,7 @@ pub enum ResponseItem { phase: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, AgentMessage { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -942,7 +965,7 @@ pub enum ResponseItem { content: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, Reasoning { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -955,7 +978,7 @@ pub enum ResponseItem { encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, LocalShellCall { /// Legacy id field retained for compatibility with older payloads. @@ -968,7 +991,7 @@ pub enum ResponseItem { action: LocalShellAction, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, FunctionCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -985,7 +1008,7 @@ pub enum ResponseItem { call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ToolSearchCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1000,7 +1023,7 @@ pub enum ResponseItem { arguments: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // NOTE: The `output` field for `function_call_output` uses a dedicated payload type with // custom serialization. On the wire it is either: @@ -1017,7 +1040,7 @@ pub enum ResponseItem { output: FunctionCallOutputPayload, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, CustomToolCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1032,7 +1055,7 @@ pub enum ResponseItem { input: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // `custom_tool_call_output.output` uses the same wire encoding as // `function_call_output.output` so freeform tools can return either plain @@ -1050,7 +1073,7 @@ pub enum ResponseItem { output: FunctionCallOutputPayload, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ToolSearchOutput { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1063,7 +1086,7 @@ pub enum ResponseItem { tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Emitted by the Responses API when the agent triggers a web search. // Example payload (from SSE `response.output_item.done`): @@ -1085,7 +1108,7 @@ pub enum ResponseItem { action: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Emitted by the Responses API when the agent triggers image generation. // Example payload: @@ -1107,7 +1130,7 @@ pub enum ResponseItem { result: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, #[serde(alias = "compaction_summary")] Compaction { @@ -1117,15 +1140,10 @@ pub enum ResponseItem { encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, - }, - // Compaction triggers are request controls, and the Responses API does not - // accept an `id` field for them. - CompactionTrigger { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, + // Compaction triggers are request controls, not durable response items. + CompactionTrigger {}, ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1135,7 +1153,7 @@ pub enum ResponseItem { encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, #[serde(other)] Other, @@ -1150,7 +1168,8 @@ impl ResponseItem { /// Returns the non-empty Responses API item ID, if present. pub fn id(&self) -> Option<&str> { match self { - Self::Message { id, .. } + Self::AdditionalTools { id, .. } + | Self::Message { id, .. } | Self::AgentMessage { id, .. } | Self::LocalShellCall { id, .. } | Self::FunctionCall { id, .. } @@ -1171,7 +1190,8 @@ impl ResponseItem { /// Sets or clears the Responses API item ID for variants that carry one. pub fn set_id(&mut self, new_id: Option) { match self { - Self::Message { id, .. } + Self::AdditionalTools { id, .. } + | Self::Message { id, .. } | Self::AgentMessage { id, .. } | Self::LocalShellCall { id, .. } | Self::FunctionCall { id, .. } @@ -1191,70 +1211,152 @@ impl ResponseItem { /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { - self.metadata() + self.internal_chat_message_metadata_passthrough() .and_then(|metadata| metadata.turn_id.as_deref()) .filter(|turn_id| !turn_id.is_empty()) } /// Stamps the item with `turn_id` unless it already has a non-empty turn ID. - pub fn stamp_turn_id_if_missing(&mut self, turn_id: &str) { - if turn_id.is_empty() || self.turn_id().is_some() { - return; - } - let Some(metadata) = self.metadata_mut() else { + pub fn set_turn_id_if_missing(&mut self, turn_id: &str) { + let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else { return; }; - metadata - .get_or_insert_with(ResponseItemMetadata::default) - .turn_id = Some(turn_id.to_string()); + InternalChatMessageMetadataPassthrough::set_turn_id_if_missing(metadata, turn_id); } - /// Removes Responses API item metadata before sending to a provider that does not accept it. - pub fn clear_metadata(&mut self) { - if let Some(metadata) = self.metadata_mut() { + /// Removes internal chat message metadata passthrough before sending to a provider that does + /// not accept it. + pub fn clear_internal_chat_message_metadata_passthrough(&mut self) { + if let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() { *metadata = None; } } - fn metadata(&self) -> Option<&ResponseItemMetadata> { + fn internal_chat_message_metadata_passthrough( + &self, + ) -> Option<&InternalChatMessageMetadataPassthrough> { match self { - Self::Message { metadata, .. } - | Self::AgentMessage { metadata, .. } - | Self::Reasoning { metadata, .. } - | Self::LocalShellCall { metadata, .. } - | Self::FunctionCall { metadata, .. } - | Self::ToolSearchCall { metadata, .. } - | Self::FunctionCallOutput { metadata, .. } - | Self::CustomToolCall { metadata, .. } - | Self::CustomToolCallOutput { metadata, .. } - | Self::ToolSearchOutput { metadata, .. } - | Self::WebSearchCall { metadata, .. } - | Self::ImageGenerationCall { metadata, .. } - | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata, .. } - | Self::ContextCompaction { metadata, .. } => metadata.as_ref(), - Self::Other => None, + Self::Message { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::AgentMessage { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Reasoning { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::LocalShellCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::WebSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ImageGenerationCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Compaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => metadata.as_ref(), + Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None, } } - fn metadata_mut(&mut self) -> Option<&mut Option> { + fn internal_chat_message_metadata_passthrough_mut( + &mut self, + ) -> Option<&mut Option> { match self { - Self::Message { metadata, .. } - | Self::AgentMessage { metadata, .. } - | Self::Reasoning { metadata, .. } - | Self::LocalShellCall { metadata, .. } - | Self::FunctionCall { metadata, .. } - | Self::ToolSearchCall { metadata, .. } - | Self::FunctionCallOutput { metadata, .. } - | Self::CustomToolCall { metadata, .. } - | Self::CustomToolCallOutput { metadata, .. } - | Self::ToolSearchOutput { metadata, .. } - | Self::WebSearchCall { metadata, .. } - | Self::ImageGenerationCall { metadata, .. } - | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata, .. } - | Self::ContextCompaction { metadata, .. } => Some(metadata), - Self::Other => None, + Self::Message { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::AgentMessage { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Reasoning { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::LocalShellCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::WebSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ImageGenerationCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Compaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => Some(metadata), + Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None, } } } @@ -1495,13 +1597,13 @@ impl From for ResponseItem { content, id: None, phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput { id: None, call_id, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.into_function_call_output_payload(); @@ -1509,7 +1611,7 @@ impl From for ResponseItem { id: None, call_id, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } ResponseInputItem::CustomToolCallOutput { @@ -1521,7 +1623,7 @@ impl From for ResponseItem { call_id, name, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::ToolSearchOutput { call_id, @@ -1534,7 +1636,7 @@ impl From for ResponseItem { execution, tools, id: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, } } @@ -2087,14 +2189,15 @@ mod tests { text: "still working".to_string(), }], phase: Some(MessagePhase::Commentary), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } #[test] - fn response_item_metadata_round_trips_and_stamps_turn_ids() -> Result<()> { - let mut item = response_item_with_metadata(Some(response_item_metadata("turn-1"))); + fn response_item_passthrough_metadata_round_trips_and_stamps_turn_ids() -> Result<()> { + let mut item = + response_item_with_passthrough_metadata(Some(passthrough_metadata("turn-1"))); let round_trip: ResponseItem = serde_json::from_value(serde_json::to_value(&item)?)?; assert_eq!(round_trip, item); @@ -2102,34 +2205,39 @@ mod tests { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}], - "metadata": { + "internal_chat_message_metadata_passthrough": { "turn_id": "turn-1", "other": "ignored", }, }))?; assert_eq!(unknown_metadata, item); - item.stamp_turn_id_if_missing("turn-2"); + item.set_turn_id_if_missing("turn-2"); assert_eq!(item.turn_id(), Some("turn-1")); - let mut empty_turn_id = response_item_with_metadata(Some(response_item_metadata(""))); - empty_turn_id.stamp_turn_id_if_missing("turn-1"); + let mut empty_turn_id = + response_item_with_passthrough_metadata(Some(passthrough_metadata(""))); + empty_turn_id.set_turn_id_if_missing("turn-1"); assert_eq!(empty_turn_id.turn_id(), Some("turn-1")); - let mut missing_turn_id = response_item_with_metadata(/*metadata*/ None); - missing_turn_id.stamp_turn_id_if_missing(""); - missing_turn_id.stamp_turn_id_if_missing("turn-1"); + let mut missing_turn_id = response_item_with_passthrough_metadata( + /*internal_chat_message_metadata_passthrough*/ None, + ); + missing_turn_id.set_turn_id_if_missing(""); + missing_turn_id.set_turn_id_if_missing("turn-1"); assert_eq!(missing_turn_id.turn_id(), Some("turn-1")); let mut other = ResponseItem::Other; - other.stamp_turn_id_if_missing("turn-1"); + other.set_turn_id_if_missing("turn-1"); assert_eq!(other.turn_id(), None); Ok(()) } #[test] fn response_item_id_getter_and_setter() { - let mut item = response_item_with_metadata(/*metadata*/ None); + let mut item = response_item_with_passthrough_metadata( + /*internal_chat_message_metadata_passthrough*/ None, + ); assert_eq!(item.id(), None); item.set_id(Some("msg_test".to_string())); @@ -2139,9 +2247,19 @@ mod tests { item.set_id(/*new_id*/ None); assert_eq!(item.id(), None); + + let mut additional_tools = ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: Vec::new(), + }; + additional_tools.set_id(Some("at_test".to_string())); + assert_eq!(additional_tools.id(), Some("at_test")); } - fn response_item_with_metadata(metadata: Option) -> ResponseItem { + fn response_item_with_passthrough_metadata( + internal_chat_message_metadata_passthrough: Option, + ) -> ResponseItem { ResponseItem::Message { id: None, role: "user".to_string(), @@ -2149,14 +2267,13 @@ mod tests { text: "hello".to_string(), }], phase: None, - metadata, + internal_chat_message_metadata_passthrough, } } - fn response_item_metadata(turn_id: &str) -> ResponseItemMetadata { - ResponseItemMetadata { + fn passthrough_metadata(turn_id: &str) -> InternalChatMessageMetadataPassthrough { + InternalChatMessageMetadataPassthrough { turn_id: Some(turn_id.to_string()), - ..Default::default() } } @@ -2261,7 +2378,7 @@ mod tests { status: "completed".to_string(), revised_prompt: Some("A small blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2283,7 +2400,7 @@ mod tests { status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2635,7 +2752,7 @@ mod tests { namespace: Some("mcp__codex_apps__gmail".to_string()), arguments: "{\"top_k\":5}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2983,7 +3100,7 @@ mod tests { ResponseItem::Compaction { id: None, encrypted_content: "abc".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -3000,7 +3117,7 @@ mod tests { ResponseItem::ContextCompaction { id: None, encrypted_content: Some("abc".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -3008,7 +3125,7 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { metadata: None }; + let item = ResponseItem::CompactionTrigger {}; assert_eq!( serde_json::to_value(item)?, @@ -3019,30 +3136,13 @@ mod tests { Ok(()) } - #[test] - fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { metadata: None }; - item.stamp_turn_id_if_missing("turn-1"); - - assert_eq!( - serde_json::to_value(item)?, - serde_json::json!({ - "type": "compaction_trigger", - "metadata": { - "turn_id": "turn-1", - }, - }) - ); - Ok(()) - } - #[test] fn deserializes_compaction_trigger_without_payload() -> Result<()> { let json = r#"{"type":"compaction_trigger"}"#; let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); + assert_eq!(item, ResponseItem::CompactionTrigger {}); Ok(()) } @@ -3134,7 +3234,7 @@ mod tests { id: expected_id.clone(), status: expected_status.clone(), action: expected_action.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(parsed, expected); @@ -3219,7 +3319,7 @@ mod tests { "query": "calendar create", "limit": 1, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3281,7 +3381,7 @@ mod tests { "additionalProperties": false, } })], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3335,7 +3435,7 @@ mod tests { arguments: serde_json::json!({ "paths": ["crm"], }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3356,7 +3456,7 @@ mod tests { status: "completed".to_string(), execution: "server".to_string(), tools: vec![], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1fe732c4ff..8a678281d2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -10,6 +10,7 @@ use std::ops::Mul; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use strum_macros::EnumIter; @@ -38,11 +39,11 @@ use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; +use crate::models::InternalChatMessageMetadataPassthrough; use crate::models::MessagePhase; use crate::models::PermissionProfile; use crate::models::ResponseInputItem; use crate::models::ResponseItem; -use crate::models::ResponseItemMetadata; use crate::models::SandboxEnforcement; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; @@ -57,7 +58,9 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::de::Error as _; use serde_json::Value; use serde_with::serde_as; use strum_macros::Display; @@ -91,8 +94,8 @@ use crate::permissions::default_read_only_subpaths_for_writable_root; pub use crate::request_permissions::RequestPermissionsArgs; pub use crate::request_user_input::RequestUserInputEvent; -/// Open/close tags for special user-input blocks. Used across crates to avoid -/// duplicated hardcoded strings. +/// Open/close tags for special context blocks. Used across crates to avoid duplicated hardcoded +/// strings. pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; @@ -109,6 +112,8 @@ pub const MULTI_AGENT_MODE_OPEN_TAG: &str = ""; pub const MULTI_AGENT_MODE_CLOSE_TAG: &str = ""; pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; +pub const CONTEXT_WINDOW_OPEN_TAG: &str = ""; +pub const CONTEXT_WINDOW_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; // TODO(anp): Replace `TurnEnvironmentSelection` with `PathUri` once path URIs carry environment @@ -697,7 +702,7 @@ pub struct InterAgentCommunication { pub encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - pub metadata: Option, + pub internal_chat_message_metadata_passthrough: Option, pub trigger_turn: bool, } @@ -715,7 +720,7 @@ impl InterAgentCommunication { other_recipients, content, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, trigger_turn, } } @@ -733,16 +738,25 @@ impl InterAgentCommunication { other_recipients, content: String::new(), encrypted_content: Some(encrypted_content), - metadata: None, + internal_chat_message_metadata_passthrough: None, trigger_turn, } } + pub fn set_turn_id_if_missing(&mut self, turn_id: &str) { + InternalChatMessageMetadataPassthrough::set_turn_id_if_missing( + &mut self.internal_chat_message_metadata_passthrough, + turn_id, + ); + } + pub fn to_response_input_item(&self) -> ResponseInputItem { + let mut communication = self.clone(); + communication.internal_chat_message_metadata_passthrough = None; ResponseInputItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: serde_json::to_string(self).unwrap_or_default(), + text: serde_json::to_string(&communication).unwrap_or_default(), }], phase: Some(MessagePhase::Commentary), } @@ -777,7 +791,9 @@ impl InterAgentCommunication { author: self.author.to_string(), recipient: self.recipient.to_string(), content, - metadata: self.metadata.clone(), + internal_chat_message_metadata_passthrough: self + .internal_chat_message_metadata_passthrough + .clone(), } } @@ -1251,6 +1267,9 @@ pub enum EventMsg { /// Backend moderation metadata intended for first-party turn presentation. TurnModerationMetadata(TurnModerationMetadataEvent), + /// Backend indicates that response output is waiting on a safety review. + SafetyBuffering(SafetyBufferingEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1929,6 +1948,15 @@ pub struct TurnModerationMetadataEvent { pub metadata: Value, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SafetyBufferingEvent { + pub model: String, + pub use_cases: Vec, + pub reasons: Vec, + pub show_buffering_ui: bool, + pub faster_model: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; @@ -1993,7 +2021,7 @@ pub struct ThreadSettingsSnapshot { pub personality: Option, pub collaboration_mode: CollaborationMode, #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] @@ -2433,7 +2461,7 @@ pub struct ConversationPathResponseEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ResumedHistory { pub conversation_id: ThreadId, - pub history: Vec, + pub history: Arc>, pub rollout_path: Option, } @@ -2478,11 +2506,11 @@ impl InitialHistory { } } - pub fn get_rollout_items(&self) -> Vec { + pub fn get_rollout_items(&self) -> &[RolloutItem] { match self { - InitialHistory::New | InitialHistory::Cleared => Vec::new(), - InitialHistory::Resumed(resumed) => resumed.history.clone(), - InitialHistory::Forked(items) => items.clone(), + InitialHistory::New | InitialHistory::Cleared => &[], + InitialHistory::Resumed(resumed) => &resumed.history, + InitialHistory::Forked(items) => items, } } @@ -2904,6 +2932,7 @@ pub enum MultiAgentVersion { /// and should be used when there is no config override. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { + pub session_id: SessionId, pub id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, @@ -2946,8 +2975,10 @@ pub struct SessionMeta { impl Default for SessionMeta { fn default() -> Self { + let id = ThreadId::default(); SessionMeta { - id: ThreadId::default(), + session_id: id.into(), + id, forked_from_id: None, parent_thread_id: None, timestamp: String::new(), @@ -2968,7 +2999,7 @@ impl Default for SessionMeta { } } -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[derive(Serialize, Debug, Clone, JsonSchema, TS)] pub struct SessionMetaLine { #[serde(flatten)] pub meta: SessionMeta, @@ -2976,6 +3007,35 @@ pub struct SessionMetaLine { pub git: Option, } +impl<'de> Deserialize<'de> for SessionMetaLine { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct SessionMetaLineFields { + #[serde(flatten)] + meta: SessionMeta, + git: Option, + } + + let mut value = Value::deserialize(deserializer)?; + let fields = value + .as_object_mut() + .ok_or_else(|| D::Error::custom("session metadata must be an object"))?; + if !fields.contains_key("session_id") { + let thread_id = fields + .get("id") + .cloned() + .ok_or_else(|| D::Error::missing_field("id"))?; + fields.insert("session_id".to_string(), thread_id); + } + let SessionMetaLineFields { meta, git } = + serde_json::from_value(value).map_err(D::Error::custom)?; + Ok(Self { meta, git }) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum RolloutItem { @@ -2996,6 +3056,12 @@ pub struct CompactedItem { /// Monotonic position of this context window within the thread. #[serde(default, skip_serializing_if = "Option::is_none")] pub window_number: Option, + /// UUIDv7 identity of the first context window in this thread's window chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_window_id: Option, + /// UUIDv7 identity of the context window immediately before this one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_window_id: Option, /// UUIDv7 identity of this context window. #[serde(default, skip_serializing_if = "Option::is_none")] pub window_id: Option, @@ -3010,7 +3076,7 @@ impl From for ResponseItem { text: value.message, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } } @@ -4293,22 +4359,26 @@ mod tests { #[test] fn inter_agent_communication_response_input_item_preserves_commentary_phase() { - let communication = InterAgentCommunication { + let mut communication = InterAgentCommunication { author: AgentPath::root(), recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, trigger_turn: true, }; + communication.set_turn_id_if_missing("turn-1"); + let mut serialized_communication = communication.clone(); + serialized_communication.internal_chat_message_metadata_passthrough = None; assert_eq!( communication.to_response_input_item(), ResponseInputItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: serde_json::to_string(&communication).expect("serialize communication"), + text: serde_json::to_string(&serialized_communication) + .expect("serialize communication"), }], phase: Some(MessagePhase::Commentary), } @@ -4340,7 +4410,7 @@ mod tests { encrypted_content: "encrypted payload".to_string(), }, ], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -5374,6 +5444,7 @@ mod tests { let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let older_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version: Some(MultiAgentVersion::V2), ..Default::default() @@ -5382,6 +5453,7 @@ mod tests { }; let newer_meta_without_version = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version: None, ..Default::default() diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 9ff36e349d..722f5f7d78 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -19,6 +19,7 @@ use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; +use rmcp::model::Meta; use rmcp::model::PaginatedRequestParams; use rmcp::model::RawResource; use rmcp::model::RawResourceTemplate; @@ -70,9 +71,27 @@ impl TestToolServer { ); sandbox_meta_tool.annotations = Some(ToolAnnotations::new().read_only(true)); + #[expect(clippy::expect_used)] + let thread_hint_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .expect("thread_hint tool schema should deserialize"); + let mut thread_hint_tool = Tool::new( + Cow::Borrowed("thread_hint"), + Cow::Borrowed("Return an unstructured history hint for a thread."), + Arc::new(thread_hint_schema), + ); + thread_hint_tool.annotations = Some(ToolAnnotations::new().read_only(true)); + let mut thread_hint_meta = Meta::new(); + thread_hint_meta.insert("ui".to_string(), json!({ "visibility": [] })); + thread_hint_tool.meta = Some(thread_hint_meta); + let tools = vec![ Self::echo_tool(), Self::echo_dash_tool(), + thread_hint_tool, Self::client_capabilities_tool(), Self::cwd_tool(), Self::sync_tool(), @@ -537,6 +556,22 @@ impl ServerHandler for TestToolServer { .map_err(|err| McpError::internal_error(err.to_string(), None))?; Ok(Self::structured_result(json!({ "cwd": cwd }))) } + "thread_hint" => { + let thread_id = context + .meta + .0 + .get("threadId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + McpError::invalid_params("missing threadId metadata".to_string(), None) + })?; + Ok(CallToolResult::success(vec![ + rmcp::model::Content::text(format!( + "manual history hint for thread {thread_id}" + )), + rmcp::model::Content::text("unstructured notes/thread_hint fixture result"), + ])) + } "echo" | "echo-tool" => { let args: EchoArgs = match request.arguments { Some(arguments) => serde_json::from_value(serde_json::Value::Object( diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 4a9b89de3c..003f65cd93 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::ffi::OsString; use std::future::Future; use std::io; -use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -354,7 +353,7 @@ impl RmcpClient { args: Vec, env: Option>, env_vars: &[McpServerEnvVar], - cwd: Option, + cwd: Option, launcher: Arc, ) -> io::Result { let transport_recipe = TransportRecipe::Stdio { @@ -456,7 +455,7 @@ impl RmcpClient { .peer() .peer_info() .ok_or_else(|| anyhow!("handshake succeeded but server info was missing"))?; - let initialize_result = initialize_result_rmcp.clone(); + let initialize_result = initialize_result_rmcp.as_ref().clone(); { let mut initialize_context = self.initialize_context.lock().await; diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 8ada806ff7..bc1a8924d5 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -15,6 +15,7 @@ use std::collections::HashMap; use std::ffi::OsString; use std::future::Future; use std::io; +use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; @@ -35,6 +36,7 @@ use codex_exec_server::ExecEnvPolicy; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; #[cfg(unix)] use codex_utils_pty::process_group::kill_process_group; @@ -82,7 +84,7 @@ pub struct StdioServerCommand { args: Vec, env: Option>, env_vars: Vec, - cwd: Option, + cwd: Option, } /// Client-side rmcp transport for a launched MCP stdio server. @@ -149,7 +151,7 @@ impl StdioServerCommand { args: Vec, env: Option>, env_vars: Vec, - cwd: Option, + cwd: Option, ) -> Self { Self { program, @@ -247,7 +249,7 @@ impl LocalStdioServerLauncher { } = command; let program_name = program.to_string_lossy().into_owned(); let envs = create_env_for_mcp_server(env, &env_vars).map_err(io::Error::other)?; - let cwd = cwd.unwrap_or(fallback_cwd); + let cwd = cwd.map(PathBuf::from).unwrap_or(fallback_cwd); let resolved_program = program_resolver::resolve(program, &envs, &cwd).map_err(io::Error::other)?; @@ -479,6 +481,9 @@ impl ExecutorStdioServerLauncher { "executor stdio server requires an explicit cwd", )); }; + let cwd: PathUri = LegacyAppPathString::from_path(Path::new(&cwd)) + .try_into() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; let program_name = program.to_string_lossy().into_owned(); let envs = create_env_overlay_for_remote_mcp_server(env, &env_vars); let remote_env_vars = remote_mcp_env_var_names(&env_vars); @@ -488,7 +493,6 @@ impl ExecutorStdioServerLauncher { // before sending an executor request. let argv = Self::process_api_argv(&program, &args).map_err(io::Error::other)?; let env = Self::process_api_env(envs).map_err(io::Error::other)?; - let cwd = PathUri::from_path(cwd)?; let process_id = ExecutorProcessTransport::next_process_id(); // Start the MCP server process on the executor with raw pipes. `tty=false` // keeps stdout as a clean protocol stream, while `pipe_stdin=true` lets @@ -503,6 +507,8 @@ impl ExecutorStdioServerLauncher { tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .map_err(io::Error::other)?; diff --git a/codex-rs/rmcp-client/tests/foreign_stdio_cwd.rs b/codex-rs/rmcp-client/tests/foreign_stdio_cwd.rs new file mode 100644 index 0000000000..84aca7b92a --- /dev/null +++ b/codex-rs/rmcp-client/tests/foreign_stdio_cwd.rs @@ -0,0 +1,67 @@ +use std::ffi::OsString; +use std::sync::Arc; +use std::sync::Mutex; + +use codex_exec_server::ExecBackend; +use codex_exec_server::ExecBackendFuture; +use codex_exec_server::ExecParams; +use codex_exec_server::ExecServerError; +use codex_rmcp_client::ExecutorStdioServerLauncher; +use codex_rmcp_client::RmcpClient; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; + +#[derive(Default)] +struct RecordingExecBackend { + params: Mutex>, +} + +impl ExecBackend for RecordingExecBackend { + fn start(&self, params: ExecParams) -> ExecBackendFuture<'_> { + let mut recorded_params = match self.params.lock() { + Ok(recorded_params) => recorded_params, + Err(poisoned) => poisoned.into_inner(), + }; + *recorded_params = Some(params); + Box::pin(async { + Err(ExecServerError::Protocol( + "stop after recording executor request".to_string(), + )) + }) + } +} + +#[tokio::test] +async fn executor_stdio_forwards_foreign_absolute_cwd_as_path_uri() { + #[cfg(not(windows))] + let cwd = r"C:\Users\openai\share"; + #[cfg(windows)] + let cwd = "/home/openai/share"; + #[cfg(not(windows))] + let expected_cwd: PathUri = "file:///C:/Users/openai/share" + .parse() + .expect("expected cwd should be a path URI"); + #[cfg(windows)] + let expected_cwd: PathUri = "file:///home/openai/share" + .parse() + .expect("expected cwd should be a path URI"); + let backend = Arc::new(RecordingExecBackend::default()); + let launcher = Arc::new(ExecutorStdioServerLauncher::new(backend.clone())); + + let _ = RmcpClient::new_stdio_client( + OsString::from("echo"), + Vec::new(), + /*env*/ None, + &[], + Some(cwd.to_string()), + launcher, + ) + .await; + let params = backend + .params + .lock() + .expect("recorded params lock should not be poisoned") + .take() + .expect("executor start request should be recorded"); + assert_eq!(params.cwd, expected_cwd); +} diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index 7afdcf5847..66f26f9758 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -504,7 +504,7 @@ mod tests { text: "raw reasoning".to_string(), }]), encrypted_content: Some("encoded".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let normal = serde_json::to_value(&item).expect("response item serializes"); diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a998c98fc0..42e0db04d2 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -344,6 +344,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s EventMsg::Warning(_) => Some("warning"), EventMsg::ShutdownComplete => Some("shutdown_complete"), EventMsg::GuardianWarning(_) + | EventMsg::SafetyBuffering(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) diff --git a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs index e7a3253f6f..f97cc65f67 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs @@ -37,6 +37,9 @@ pub(super) fn normalize_model_items( ) -> Result> { let mut normalized_items = Vec::new(); for item in items { + if item.get("type").and_then(Value::as_str) == Some("additional_tools") { + continue; + } normalized_items.push(normalize_model_item(item, raw_payload)?); } Ok(normalized_items) diff --git a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs index 1cf13b1fa8..18d38a4170 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs @@ -735,6 +735,48 @@ fn unsupported_model_item_is_reducer_error() -> anyhow::Result<()> { ) } +#[test] +fn additional_tools_are_excluded_from_request_conversation() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let writer = create_started_writer(&temp)?; + start_turn(&writer, "turn-1")?; + + let request = writer.write_json_payload( + RawPayloadKind::InferenceRequest, + &json!({ + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{ + "type": "function", + "name": "lookup", + "parameters": {"type": "object", "properties": {}} + }] + }, + message("user", "find it") + ] + }), + )?; + append_inference_start(&writer, "inference-1", "turn-1", request)?; + + let rollout = replay_bundle(temp.path())?; + let request_item_ids = &rollout.inference_calls["inference-1"].request_item_ids; + + assert_eq!(request_item_ids.len(), 1); + assert_eq!(rollout.conversation_items.len(), 1); + assert_eq!( + rollout.conversation_items[&request_item_ids[0]].body, + ConversationBody { + parts: vec![ConversationPart::Text { + text: "find it".to_string(), + }], + } + ); + + Ok(()) +} + #[test] fn missing_request_input_is_reducer_error() -> anyhow::Result<()> { let temp = TempDir::new()?; diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index c2a8b7d7d3..60f47d078f 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -456,6 +456,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> fs::create_dir_all(parent)?; let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 4467439552..5f923f16d0 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -33,6 +33,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, @@ -88,6 +89,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, @@ -155,6 +157,8 @@ fn builder_from_items_falls_back_to_filename() { message: "noop".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, })]; @@ -353,6 +357,7 @@ fn write_rollout_in_sessions_with_cwd( std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir"); let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 169f4360d3..4db26576b0 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -44,8 +44,9 @@ pub fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, - ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::Other => false, + ResponseItem::AdditionalTools { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::Other => false, } } @@ -62,7 +63,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } @@ -121,6 +123,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) + | EventMsg::SafetyBuffering(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::TurnModerationMetadata(_) diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index c503f2a95d..24bf9d1270 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::sync::Mutex; use chrono::SecondsFormat; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; @@ -95,10 +96,11 @@ pub struct RolloutRecorder { #[allow(clippy::large_enum_variant)] pub enum RolloutRecorderParams { Create { + session_id: SessionId, conversation_id: ThreadId, forked_from_id: Option, parent_thread_id: Option, - source: SessionSource, + source: Box, thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, @@ -182,10 +184,11 @@ impl RolloutRecorderParams { dynamic_tools: Vec, ) -> Self { Self::Create { + session_id: conversation_id.into(), conversation_id, forked_from_id, parent_thread_id, - source, + source: Box::new(source), thread_source, base_instructions, dynamic_tools, @@ -194,6 +197,13 @@ impl RolloutRecorderParams { } } + pub fn with_session_id(mut self, session_id: SessionId) -> Self { + if let Self::Create { session_id: id, .. } = &mut self { + *id = session_id; + } + self + } + pub fn with_multi_agent_version( mut self, multi_agent_version: Option, @@ -727,6 +737,7 @@ impl RolloutRecorder { let (file, deferred_log_file_info, rollout_path, meta, initial_rollout_copy) = match params { RolloutRecorderParams::Create { + session_id, conversation_id, forked_from_id, parent_thread_id, @@ -739,7 +750,7 @@ impl RolloutRecorder { } => { let log_file_info = precompute_log_file_info(config, conversation_id)?; let path = log_file_info.path.clone(); - let session_id = log_file_info.conversation_id; + let thread_id = log_file_info.conversation_id; let started_at = log_file_info.timestamp; let timestamp_format: &[FormatItem] = format_description!( @@ -751,7 +762,8 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; let session_meta = SessionMeta { - id: session_id, + session_id, + id: thread_id, forked_from_id, parent_thread_id, timestamp, @@ -761,7 +773,7 @@ impl RolloutRecorder { agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), agent_path: source.get_agent_path().map(Into::into), - source, + source: *source, thread_source, model_provider: Some(config.model_provider_id().to_string()), base_instructions: Some(base_instructions), @@ -1052,7 +1064,7 @@ impl RolloutRecorder { info!("Resumed rollout successfully from {path:?}"); Ok(InitialHistory::Resumed(ResumedHistory { conversation_id, - history: items, + history: Arc::new(items), rollout_path: Some(compression::plain_rollout_path(path)), })) } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 49f334ddfd..e8ef9e1618 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::config::RolloutConfig; use chrono::TimeZone; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; @@ -45,6 +46,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result anyhow::Result<()> { let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -145,7 +148,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { } #[tokio::test] -async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> { +async fn load_rollout_items_defaults_legacy_session_id() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let rollout_path = home.path().join("rollout.jsonl"); let mut file = File::create(&rollout_path)?; @@ -210,7 +213,10 @@ async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Resu assert_eq!(loaded_thread_id, Some(thread_id)); assert_eq!(parse_errors, 0); assert_eq!(items.len(), 2); - assert!(matches!(items[0], RolloutItem::SessionMeta(_))); + let RolloutItem::SessionMeta(session_meta) = &items[0] else { + panic!("expected session metadata"); + }; + assert_eq!(session_meta.meta.session_id, SessionId::from(thread_id)); assert!(matches!( items[1], RolloutItem::ResponseItem(ResponseItem::Message { .. }) @@ -234,6 +240,7 @@ async fn load_rollout_items_preserves_legacy_guardian_assessment_lines() -> std: "timestamp": ts, "type": "session_meta", "payload": { + "session_id": thread_id, "id": thread_id, "timestamp": ts, "cwd": ".", @@ -297,6 +304,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo "timestamp": ts, "type": "session_meta", "payload": { + "session_id": thread_id, "id": thread_id, "timestamp": ts, "cwd": ".", @@ -365,6 +373,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let session_id = SessionId::default(); let thread_id = ThreadId::new(); let recorder = RolloutRecorder::new( &config, @@ -376,7 +385,8 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< /*thread_source*/ None, BaseInstructions::default(), Vec::new(), - ), + ) + .with_session_id(session_id), ) .await?; @@ -421,10 +431,12 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< assert!(rollout_path.exists(), "rollout file should be materialized"); let text = std::fs::read_to_string(&rollout_path)?; - assert!( - text.contains("\"type\":\"session_meta\""), - "expected session metadata in rollout" - ); + let first_line = text.lines().next().expect("session metadata line"); + let session_meta: RolloutLine = serde_json::from_str(first_line)?; + let RolloutItem::SessionMeta(session_meta) = session_meta.item else { + panic!("expected session metadata in rollout"); + }; + assert_eq!(session_meta.meta.session_id, session_id); let buffered_idx = text .find("buffered-event") .expect("buffered event in rollout"); @@ -733,6 +745,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts, "cwd": home.path().display().to_string(), diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 97496d394e..33e4ae4a59 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -25,6 +25,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res timestamp: timestamp.clone(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index ba92ca27f1..4b2ae4f25c 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -158,6 +158,7 @@ fn write_rollout_with_user_message( timestamp: "2026-06-01T14:26:25Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 56f84bd392..06cac3151f 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -298,6 +298,7 @@ fn write_session_file_with_provider( let mut file = File::create(file_path)?; let mut payload = serde_json::json!({ + "session_id": uuid, "id": uuid, "timestamp": ts_str, "cwd": ".", @@ -371,6 +372,7 @@ fn write_goal_started_session_file( "timestamp": ts_str, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts_str, "cwd": ".", @@ -452,6 +454,7 @@ fn write_session_file_with_delayed_user_event( Uuid::from_u128(100 + i as u128) }; let payload = serde_json::json!({ + "session_id": uuid, "id": id, "timestamp": ts_str, "cwd": ".", @@ -484,8 +487,9 @@ fn write_session_file_with_meta_payload( root: &Path, ts_str: &str, uuid: Uuid, - payload: serde_json::Value, + mut payload: serde_json::Value, ) -> std::io::Result<()> { + payload["session_id"] = serde_json::json!(uuid); let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); let dt = PrimitiveDateTime::parse(ts_str, format) @@ -1089,6 +1093,7 @@ async fn test_get_thread_contents() { "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts, "cwd": ".", @@ -1268,6 +1273,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { timestamp: ts.to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, @@ -1315,7 +1321,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { text: format!("reply-{idx}"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }; writeln!(file, "{}", serde_json::to_string(&response_line)?)?; diff --git a/codex-rs/sandboxing/src/denial.rs b/codex-rs/sandboxing/src/denial.rs new file mode 100644 index 0000000000..f355fa1c02 --- /dev/null +++ b/codex-rs/sandboxing/src/denial.rs @@ -0,0 +1,57 @@ +use codex_protocol::exec_output::ExecToolCallOutput; + +use crate::SandboxType; + +/// Returns whether a failed command was likely denied by the selected sandbox. +pub fn is_likely_sandbox_denied( + sandbox_type: SandboxType, + exec_output: &ExecToolCallOutput, +) -> bool { + if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { + return false; + } + + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", + ]; + + let has_sandbox_keyword = [ + &exec_output.stderr.text, + &exec_output.stdout.text, + &exec_output.aggregated_output.text, + ] + .into_iter() + .any(|section| { + let lower = section.to_lowercase(); + SANDBOX_DENIED_KEYWORDS + .iter() + .any(|needle| lower.contains(needle)) + }); + + if has_sandbox_keyword { + return true; + } + + const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; + if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { + return false; + } + + #[cfg(unix)] + { + const EXIT_CODE_SIGNAL_BASE: i32 = 128; + if sandbox_type == SandboxType::LinuxSeccomp + && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS + { + return true; + } + } + + false +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 22c4984c5a..0688d0f920 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(target_os = "linux")] mod bwrap; +mod denial; pub mod landlock; mod manager; pub mod policy_transforms; @@ -11,6 +12,7 @@ mod windows; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; +pub use denial::is_likely_sandbox_denied; pub use manager::SandboxCommand; pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index dd7aba3614..29b1177b11 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -282,24 +282,36 @@ impl SandboxManager { windows_sandbox_level: WindowsSandboxLevel, has_managed_network_requirements: bool, ) -> SandboxType { + if self.should_sandbox( + file_system_policy, + network_policy, + pref, + has_managed_network_requirements, + ) { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } + } + + /// Returns whether the request needs a sandbox, independently of whether + /// this host can provide a concrete sandbox implementation. + pub fn should_sandbox( + &self, + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + pref: SandboxablePreference, + has_managed_network_requirements: bool, + ) -> bool { match pref { - SandboxablePreference::Forbid => SandboxType::None, - SandboxablePreference::Require => { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } - SandboxablePreference::Auto => { - if should_require_platform_sandbox( - file_system_policy, - network_policy, - has_managed_network_requirements, - ) { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } - } + SandboxablePreference::Forbid => false, + SandboxablePreference::Require => true, + SandboxablePreference::Auto => should_require_platform_sandbox( + file_system_policy, + network_policy, + has_managed_network_requirements, + ), } } diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 b/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 index de696c88bd..92291e7c98 100644 --- a/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 +++ b/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 @@ -42,6 +42,21 @@ function Invoke-ParseRequest { return @{ id = $RequestId; status = 'parse_errors' } } + # Top-level AST regions and collections outside the end-block statement list + # can execute code that the command lowering below does not inspect. + $cleanBlock = $ast.PSObject.Properties['CleanBlock'] + if ( + $ast.ParamBlock -ne $null -or + $ast.DynamicParamBlock -ne $null -or + $ast.BeginBlock -ne $null -or + $ast.ProcessBlock -ne $null -or + ($cleanBlock -ne $null -and $cleanBlock.Value -ne $null) -or + $ast.UsingStatements.Count -gt 0 -or + $ast.EndBlock.Traps.Count -gt 0 + ) { + return @{ id = $RequestId; status = 'unsupported' } + } + # PowerShell's stop-parsing marker hands the remaining source text to native # commands with runtime argument handling that does not match the AST shape we # flatten below. Keep that form out of the argv-like lowering path entirely. diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser.rs b/codex-rs/shell-command/src/command_safety/powershell_parser.rs index ddcbdf5727..00b18af6f0 100644 --- a/codex-rs/shell-command/src/command_safety/powershell_parser.rs +++ b/codex-rs/shell-command/src/command_safety/powershell_parser.rs @@ -310,4 +310,62 @@ mod tests { .unwrap(); assert_eq!(parsed, PowershellParseOutcome::Unsupported); } + + #[test] + fn parser_process_rejects_param_blocks() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse("param([string]$path = (Get-Location)) Write-Output test") + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } + + #[test] + fn parser_process_rejects_named_blocks() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse("begin { Set-Content codex_poc.txt pwned } end { Get-Content Cargo.toml }") + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } + + #[test] + fn parser_process_rejects_using_statements() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse("using module ./codex_poc.psm1\nGet-Content Cargo.toml") + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } + + #[test] + fn parser_process_rejects_trap_blocks() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse( + "trap { Set-Content codex_poc.txt pwned; continue } Get-Content missing -ErrorAction Stop", + ) + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } } diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs index a8dcef6de6..df2025253b 100644 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs @@ -412,6 +412,46 @@ mod tests { ]))); } + #[test] + fn rejects_powershell_param_blocks() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "param([string]$path = (Get-Location)) Write-Output test", + ]))); + } + + #[test] + fn rejects_powershell_named_blocks() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "begin { Set-Content codex_poc.txt pwned } end { Get-Content Cargo.toml }", + ]))); + } + + #[test] + fn rejects_powershell_using_statements() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "using module ./codex_poc.psm1\nGet-Content Cargo.toml", + ]))); + } + + #[test] + fn rejects_powershell_trap_blocks() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "trap { Set-Content codex_poc.txt pwned; continue } Get-Content missing -ErrorAction Stop", + ]))); + } + #[test] fn rejects_powershell_commands_with_side_effects() { assert!(!is_safe_command_windows(&vec_str(&[ diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index ec5476ae14..40d3ae6b54 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -36,6 +36,7 @@ "brandColor": "#3B82F6", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", "screenshots": [ "./assets/screenshot1.png", "./assets/screenshot2.png", @@ -105,6 +106,7 @@ Or as an object directly in `plugin.json`: - `brandColor` (`string`): Theme color for the plugin card. - `composerIcon` (`string`): Path to icon asset. - `logo` (`string`): Path to logo asset. +- `logoDark` (`string`): Optional path to the logo asset used in dark mode. - `screenshots` (`array` of `string`): List of screenshot asset paths. - Screenshot entries must be PNG filenames and stored under `./assets/`. - Keep file paths relative to plugin root. @@ -205,7 +207,7 @@ personal marketplace unless the caller explicitly requests a repo-local destinat - `version` must use strict semver. - `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when present. -- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when +- `composerIcon`, `logo`, `logoDark`, and `screenshots` must point to real files inside the plugin archive when present. - `apps` should appear in `plugin.json` only when `.app.json` actually exists. - `mcpServers` may point to `.mcp.json` or contain the MCP server object directly in diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py index 5c6fae8e63..88fae0fd00 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -153,6 +153,7 @@ def validate_manifest_shape( "brandColor", "composerIcon", "logo", + "logoDark", "screenshots", "defaultPrompt", "default_prompt", @@ -184,7 +185,7 @@ def validate_manifest_shape( not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None ): errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`") - for field in ("composerIcon", "logo"): + for field in ("composerIcon", "logo", "logoDark"): validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors) screenshots = interface.get("screenshots", []) if not isinstance(screenshots, list): diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index e156cc9113..aa1dd04036 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -191,7 +191,7 @@ mod tests { text: "hello from response item".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); apply_rollout_item(&mut metadata, &item, "test-provider"); @@ -321,6 +321,7 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: Some( ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"), @@ -513,6 +514,7 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 71d2afbc01..2cf0f5d9af 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -30,11 +30,13 @@ use tokio::sync::oneshot; use tracing::Event; use tracing::field::Field; use tracing::field::Visit; +use tracing::level_filters::LevelFilter; use tracing::span::Attributes; use tracing::span::Id; use tracing::span::Record; use tracing_subscriber::Layer; use tracing_subscriber::field::RecordFields; +use tracing_subscriber::filter::Targets; use tracing_subscriber::fmt::FormatFields; use tracing_subscriber::fmt::FormattedFields; use tracing_subscriber::fmt::format::DefaultFields; @@ -48,6 +50,14 @@ const LOG_QUEUE_CAPACITY: usize = 512; const LOG_BATCH_SIZE: usize = 128; const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(2); +pub fn default_filter() -> Targets { + Targets::new() + .with_default(LevelFilter::TRACE) + .with_target("log", LevelFilter::OFF) + .with_target("codex_otel.log_only", LevelFilter::OFF) + .with_target("codex_otel.trace_safe", LevelFilter::OFF) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct LogSinkQueueConfig { pub queue_capacity: usize, @@ -189,6 +199,13 @@ where fn on_event(&self, event: &Event<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) { let metadata = event.metadata(); + // `tracing-log` checks filters with the original log target before + // dispatching an event whose tracing target is `log`, so the outer + // target filter cannot reliably reject these bridged events. + if metadata.target() == "log" { + return; + } + // The SDK emits DEBUG timer meta-events every second per process; these // were over 30% of retained logs in measured high-fanout Codex environments. if metadata.target() == "opentelemetry_sdk" diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 576f760264..a448dce56b 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1957,6 +1957,7 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -2018,6 +2019,7 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index b9796a55cb..3d8561a176 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -280,6 +280,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), multi_agent_v2: MultiAgentV2Config::default(), + token_budget: None, rollout_budget: None, current_time_reminder: None, features: Default::default(), diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index b420e0f7b6..0070c5db52 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -110,6 +110,7 @@ mod tests { ] { store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, @@ -232,6 +233,7 @@ impl InMemoryThreadStore { let mut state = self.state.lock().await; state.calls.create_thread += 1; let session_meta = SessionMeta { + session_id: params.session_id, id: params.thread_id, forked_from_id: params.forked_from_id, parent_thread_id: params.parent_thread_id, @@ -265,7 +267,9 @@ impl InMemoryThreadStore { let mut state = self.state.lock().await; state.calls.resume_thread += 1; if let Some(history) = params.history { - state.histories.insert(params.thread_id, history); + state + .histories + .insert(params.thread_id, Arc::unwrap_or_clone(history)); } else { state.histories.entry(params.thread_id).or_default(); } diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index ba7319c609..8cebd535da 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -130,7 +130,7 @@ impl LiveThread { let include_archived = params.include_archived; let metadata = params.metadata.clone(); let metadata_sync = observed_history - .or(params.history.as_deref()) + .or_else(|| params.history.as_deref().map(Vec::as_slice)) .map(|history| { ThreadMetadataSync::for_resume_parts(thread_id, &metadata, Some(history)) }); diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index a849da4fea..6d0e151c51 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -36,6 +36,7 @@ pub(super) async fn create_thread( params.base_instructions, params.dynamic_tools, ) + .with_session_id(params.session_id) .with_multi_agent_version(params.multi_agent_version) .with_initial_rollout_copy(params.initial_rollout_copy), ) diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 056d9444a3..8d4665b79c 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -513,7 +513,7 @@ mod tests { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("tool output".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::EventMsg(EventMsg::TokenCount( codex_protocol::protocol::TokenCountEvent { @@ -1123,6 +1123,7 @@ mod tests { fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 6ae2fe6e36..bb4e36970e 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -817,6 +817,7 @@ mod tests { "timestamp": "2025-01-03T12:00:00Z", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12:00:00Z", "cwd": rollout_cwd, @@ -922,6 +923,7 @@ mod tests { "timestamp": "2025-01-03T12-00-00", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12-00-00", "cwd": home.path(), @@ -1087,6 +1089,7 @@ mod tests { "timestamp": "2025-01-03T12:00:00Z", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12:00:00Z", "cwd": home.path(), diff --git a/codex-rs/thread-store/src/local/test_support.rs b/codex-rs/thread-store/src/local/test_support.rs index 98321880ff..10a55c4fd6 100644 --- a/codex-rs/thread-store/src/local/test_support.rs +++ b/codex-rs/thread-store/src/local/test_support.rs @@ -77,6 +77,7 @@ pub(super) fn write_session_file_with_fork( "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "forked_from_id": forked_from_id, "timestamp": ts, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index fbe88c8318..1088053e12 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -106,7 +108,7 @@ impl ThreadMetadataSync { Self::for_resume_parts( params.thread_id, ¶ms.metadata, - params.history.as_deref(), + params.history.as_deref().map(Vec::as_slice), ) } @@ -507,6 +509,8 @@ mod tests { message: "compacted".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }); @@ -576,7 +580,7 @@ mod tests { ResumeThreadParams { thread_id, rollout_path: None, - history: Some(history), + history: Some(Arc::new(history)), include_archived: false, metadata: ThreadPersistenceMetadata { cwd: None, @@ -600,6 +604,7 @@ mod tests { fn session_meta(thread_id: ThreadId) -> SessionMetaLine { SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, timestamp: "2025-01-03T12:00:00Z".to_string(), source: SessionSource::Exec, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 9b904c5af3..0e1b8d388d 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -1,7 +1,9 @@ use std::path::PathBuf; +use std::sync::Arc; use chrono::DateTime; use chrono::Utc; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; @@ -64,6 +66,8 @@ pub struct ExtraConfig {} /// Parameters required to create a persisted thread. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateThreadParams { + /// Session id shared by the root thread and all of its subagents. + pub session_id: SessionId, /// Thread id generated by Codex before opening persistence. pub thread_id: ThreadId, /// Optional extra configuration fields for the thread. @@ -97,7 +101,7 @@ pub struct ResumeThreadParams { /// Known local rollout path when the caller resumed from a specific file. pub rollout_path: Option, /// Known replay history for the resumed thread, if already loaded by the caller. - pub history: Option>, + pub history: Option>>, /// Whether archived threads may be reopened. pub include_archived: bool, /// Metadata for future writes appended to the resumed live thread. diff --git a/codex-rs/tools/src/response_history.rs b/codex-rs/tools/src/response_history.rs index 4196d7c3ef..ab16769afd 100644 --- a/codex-rs/tools/src/response_history.rs +++ b/codex-rs/tools/src/response_history.rs @@ -95,7 +95,7 @@ mod tests { } }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2a10f00b42..6c0b83a72e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -148,6 +148,7 @@ arboard = { workspace = true } [dev-dependencies] +app_test_support = { workspace = true } codex-cli = { workspace = true } codex-mcp = { workspace = true } core_test_support = { workspace = true } diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 7e84e42271..212c45b4be 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -123,6 +123,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ModelVerification(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ModelSafetyBufferingUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::TurnModerationMetadata(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index bb05e1a3f4..4a0652166d 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -19,7 +19,6 @@ use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::RequestId; use crate::hooks_rpc::fetch_hooks_list; @@ -31,6 +30,8 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); +const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(/*millis*/ 2000); impl App { pub(super) fn fetch_mcp_inventory( @@ -88,7 +89,8 @@ impl App { .and_then(|result| result.map_err(|err| err.to_string())) } RateLimitRefreshOrigin::StartupPrefetch { .. } - | RateLimitRefreshOrigin::StatusCommand { .. } => { + | RateLimitRefreshOrigin::StatusCommand { .. } + | RateLimitRefreshOrigin::UsageMenu { .. } => { request.await.map_err(|err| err.to_string()) } }; @@ -125,7 +127,7 @@ impl App { tokio::spawn(async move { let result = tokio::time::timeout( RATE_LIMIT_RESET_REQUEST_TIMEOUT, - fetch_rate_limit_reset_credits(request_handle), + fetch_account_rate_limits(request_handle), ) .await .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) @@ -158,6 +160,29 @@ impl App { }); } + pub(super) fn refresh_status_line_workspace_headline( + &mut self, + app_server: &AppServerSession, + request_id: u64, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + WORKSPACE_HEADLINE_FETCH_TIMEOUT, + fetch_workspace_messages(request_handle), + ) + .await + .map_err(|_| "account/workspaceMessages/read timed out in TUI".to_string()) + .and_then(|result| { + result + .map(crate::workspace_messages::workspace_headline_from_response) + .map_err(|err| err.to_string()) + }); + app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result }); + }); + } + pub(super) fn send_add_credits_nudge_email( &mut self, app_server: &AppServerSession, @@ -764,24 +789,6 @@ pub(super) async fn fetch_account_token_activity( .wrap_err("account/usage/read failed in TUI") } -pub(super) async fn fetch_rate_limit_reset_credits( - request_handle: AppServerRequestHandle, -) -> Result { - let request_id = RequestId::String(format!("account-rate-limit-resets-{}", Uuid::new_v4())); - let response: GetAccountRateLimitsResponse = request_handle - .request_typed(ClientRequest::GetAccountRateLimits { - request_id, - params: None, - }) - .await - .wrap_err("account/rateLimits/read failed in TUI")?; - response.rate_limit_reset_credits.ok_or_else(|| { - color_eyre::eyre::eyre!( - "account/rateLimits/read response did not include rateLimitResetCredits" - ) - }) -} - pub(super) async fn consume_rate_limit_reset_credit_request( request_handle: AppServerRequestHandle, idempotency_key: String, @@ -796,6 +803,19 @@ pub(super) async fn consume_rate_limit_reset_credit_request( .wrap_err("account/rateLimitResetCredit/consume failed in TUI") } +pub(super) async fn fetch_workspace_messages( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("workspace-messages-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::GetWorkspaceMessages { + request_id, + params: None, + }) + .await + .wrap_err("account/workspaceMessages/read failed in TUI") +} + pub(super) async fn send_add_credits_nudge_email( request_handle: AppServerRequestHandle, credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 16e4aaa627..174c4d14e5 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -507,6 +507,9 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::OpenPluginsList { cwd, response } => { + self.chat_widget.open_plugins_list(cwd, response); + } AppEvent::PluginRemoteSectionsLoaded { cwd, marketplaces, @@ -716,6 +719,9 @@ impl App { AppEvent::RefreshTokenActivity { request_id } => { self.refresh_token_activity(app_server, request_id); } + AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => { + self.refresh_status_line_workspace_headline(app_server, request_id); + } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; } @@ -752,15 +758,14 @@ impl App { AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(response) => { let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); - for snapshot in app_server_rate_limit_snapshots(response) { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); - } + let snapshots = app_server_rate_limit_snapshots(response); match origin { RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { if self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -773,6 +778,7 @@ impl App { RateLimitRefreshOrigin::ResetConsume { request_id } => { self.chat_widget.finish_post_consume_reset_credits_refresh( request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -782,7 +788,17 @@ impl App { } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, snapshots); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + snapshots, + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); } } } @@ -794,16 +810,27 @@ impl App { } => { self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + Vec::new(), Err(err), ); } RateLimitRefreshOrigin::ResetConsume { request_id } => { - self.chat_widget - .finish_post_consume_reset_credits_refresh(request_id, Err(err)); + self.chat_widget.finish_post_consume_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, Vec::new()); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + Vec::new(), + Err(err), + ); } } } @@ -816,15 +843,29 @@ impl App { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); self.refresh_rate_limit_reset_credits(app_server, request_id); } - AppEvent::RateLimitResetCreditsLoaded { request_id, result } => { - if let Err(err) = &result { + AppEvent::RateLimitResetCreditsLoaded { request_id, result } => match result { + Ok(response) => { + let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + app_server_rate_limit_snapshots(response), + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); + } + Err(err) => { tracing::warn!( "account/rateLimits/read failed during reset-credit refresh: {err}" ); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } - self.chat_widget - .finish_rate_limit_reset_credits_refresh(request_id, result); - } + }, AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => { let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup(); self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key); @@ -892,6 +933,16 @@ impl App { self.sync_active_thread_personality_setting(app_server, personality) .await; } + AppEvent::SettingsSelectionClosed => { + self.app_event_tx.send(AppEvent::SettingsSelectionSettled); + } + AppEvent::SettingsSelectionSettled => { + if self.chat_widget.no_modal_or_popup_active() { + self.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ false); + self.chat_widget.maybe_send_next_queued_input(); + } + } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } @@ -2010,6 +2061,14 @@ impl App { self.chat_widget.set_status_line_git_summary(cwd, summary); self.refresh_status_line(); } + AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => { + if self + .chat_widget + .set_status_line_workspace_headline(request_id, result) + { + tui.frame_requester().schedule_frame(); + } + } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index 5d94f51c08..f12dee72cc 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -90,12 +90,15 @@ mod tests { use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; + use codex_app_server_protocol::PluginShareContext; + use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSource; use pretty_assertions::assert_eq; #[test] fn plugin_mentions_use_plugin_list_summaries_and_gui_eligibility() { let active = plugin_summary("active"); + let active_shared = shared_plugin_summary("active-shared"); let mut disabled_by_admin = plugin_summary("disabled-by-admin"); disabled_by_admin.availability = PluginAvailability::DisabledByAdmin; let mut disabled = plugin_summary("disabled"); @@ -108,7 +111,13 @@ mod tests { name: "server-marketplace".to_string(), path: None, interface: None, - plugins: vec![active, disabled_by_admin, disabled, uninstalled], + plugins: vec![ + active, + active_shared, + disabled_by_admin, + disabled, + uninstalled, + ], }], marketplace_load_errors: Vec::new(), featured_plugin_ids: Vec::new(), @@ -116,17 +125,42 @@ mod tests { assert_eq!( plugin_mentions_from_list_response(response), - vec![PluginCapabilitySummary { - config_name: "active@server-marketplace".to_string(), - display_name: "active".to_string(), - description: Some("server-marketplace".to_string()), - has_skills: false, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), - }] + vec![ + PluginCapabilitySummary { + config_name: "active@server-marketplace".to_string(), + display_name: "active".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + PluginCapabilitySummary { + config_name: "active-shared@server-marketplace".to_string(), + display_name: "active-shared".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + ] ); } + fn shared_plugin_summary(name: &str) -> PluginSummary { + PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: format!("plugins~{name}"), + remote_version: Some("7".to_string()), + discoverability: Some(PluginShareDiscoverability::Private), + share_url: Some(format!("https://chatgpt.com/codex/plugins/share/{name}")), + creator_account_user_id: None, + creator_name: Some("Test User".to_string()), + share_principals: None, + }), + ..plugin_summary(name) + } + } + fn plugin_summary(name: &str) -> PluginSummary { PluginSummary { id: format!("{name}@server-marketplace"), diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 0b69153fa5..11ce6cb9e5 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -462,7 +462,7 @@ impl App { text: SIDE_BOUNDARY_PROMPT.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0fc561cb2a..6c55accab6 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1,6 +1,7 @@ //! App-level orchestration tests for the TUI. mod model_catalog; +mod plugin_catalog; mod session_summary; mod startup; diff --git a/codex-rs/tui/src/app/tests/plugin_catalog.rs b/codex-rs/tui/src/app/tests/plugin_catalog.rs new file mode 100644 index 0000000000..251b8817bd --- /dev/null +++ b/codex-rs/tui/src/app/tests/plugin_catalog.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use codex_app_server_protocol::PluginUninstallResponse; + +use super::*; + +#[tokio::test] +async fn successful_plugin_uninstall_dispatches_plugin_list_refresh() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + while app_event_rx.try_recv().is_ok() {} + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::PluginUninstallLoaded { + cwd: cwd.clone(), + plugin_id: "plugin-docs".to_string(), + plugin_display_name: "Docs".to_string(), + result: Ok(PluginUninstallResponse {}), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let refresh_result = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match app_event_rx.recv().await { + Some(AppEvent::PluginsLoaded { + cwd: event_cwd, + result, + }) if event_cwd == cwd => break result, + Some(_) => {} + None => panic!("app event channel closed before plugin refresh completed"), + } + } + }) + .await + .expect("dispatcher should initiate a plugin list refresh"); + refresh_result.expect("plugin list refresh should succeed"); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1143848d13..55d67f2926 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -27,7 +27,6 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; @@ -119,7 +118,9 @@ pub(crate) struct PluginRemoteSectionError { /// updates the cached snapshots and any available reset-credit notice (no /// status card to finalize). A `StatusCommand` is tied to a specific `/status` /// invocation and must call `finish_status_rate_limit_refresh` when done so the -/// card stops showing a "refreshing" state. +/// card stops showing a "refreshing" state. A `UsageMenu` refreshes a cached +/// zero reset count so the disabled menu entry can become available without a +/// restart. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RateLimitRefreshOrigin { /// Eagerly fetched after bootstrap for `/status` data and reset availability. @@ -127,6 +128,8 @@ pub(crate) enum RateLimitRefreshOrigin { /// User-initiated via `/status`; the `request_id` correlates with the /// status card that should be updated when the fetch completes. StatusCommand { request_id: u64 }, + /// User reopened `/usage` while the cached reset-credit count was zero. + UsageMenu { request_id: u64 }, /// Refresh requested after a reset credit was successfully consumed. ResetConsume { request_id: u64 }, } @@ -315,7 +318,7 @@ pub(crate) enum AppEvent { /// Result of reading the current reset-credit balance. RateLimitResetCreditsLoaded { request_id: u64, - result: Result, + result: Result, }, /// Consume one reset credit using a stable idempotency key. @@ -341,6 +344,11 @@ pub(crate) enum AppEvent { result: Result, }, + /// Fetch workspace messages for the status-line headline item. + RefreshStatusLineWorkspaceHeadline { + request_id: u64, + }, + /// Commit settled asynchronous usage output after active-output barriers clear. CommitPendingUsageOutput, @@ -445,6 +453,12 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the plugin list from an already cached response. + OpenPluginsList { + cwd: PathBuf, + response: PluginListResponse, + }, + /// Result of explicitly fetching remote-backed plugin sections. PluginRemoteSectionsLoaded { cwd: PathBuf, @@ -694,6 +708,11 @@ pub(crate) enum AppEvent { /// Update the current personality in the running app and widget. UpdatePersonality(Personality), + /// Finish a settings selection after its preceding update events have been applied. + SettingsSelectionClosed, + /// Run after any nested settings events emitted while handling the close event. + SettingsSelectionSettled, + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, @@ -977,6 +996,11 @@ pub(crate) enum AppEvent { cwd: PathBuf, summary: crate::chatwidget::StatusLineGitSummary, }, + /// Async update of the workspace notification headline for status line rendering. + StatusLineWorkspaceHeadlineUpdated { + request_id: u64, + result: Result, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9ed78fed58..527cde57ed 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -311,16 +311,19 @@ impl AppServerSession { false, ), Some(Account::Chatgpt { email, plan_type }) => { - let feedback_audience = if email.ends_with("@openai.com") { + let feedback_audience = if email + .as_deref() + .is_some_and(|email| email.ends_with("@openai.com")) + { FeedbackAudience::OpenAiEmployee } else { FeedbackAudience::External }; ( - Some(email.clone()), + email.clone(), Some(TelemetryAuthMode::Chatgpt), Some(StatusAccountDisplay::ChatGpt { - email: Some(email), + email, plan: Some(plan_type_display_name(plan_type)), }), Some(plan_type), diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e011eb838c..6ca1ba93d1 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -48,7 +48,7 @@ impl SlashCommandItem { pub(crate) fn available_during_task(&self) -> bool { match self { Self::Builtin(cmd) => cmd.available_during_task(), - Self::ServiceTier(_) => false, + Self::ServiceTier(_) => true, } } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4dd62a7d20..63ce4237db 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -137,6 +137,9 @@ pub(crate) enum StatusLineItem { /// Current thread title (if set by user). ThreadTitle, + /// Current workspace notification headline. + WorkspaceHeadline, + /// Latest checklist task progress from `update_plan` (if available). TaskProgress, } @@ -185,6 +188,9 @@ impl StatusLineItem { StatusLineItem::ThreadTitle => { "Current thread title, or thread identifier when unnamed" } + StatusLineItem::WorkspaceHeadline => { + "Workspace notification headline (Enterprise workspaces only; omitted when unavailable)" + } StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" } @@ -217,6 +223,7 @@ impl StatusLineItem { StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, + StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 170c4641d2..4198943a4f 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -49,7 +49,7 @@ impl StatusLineAccent { StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::Permissions => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode, - StatusLineItem::ThreadTitle => Self::Thread, + StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index bd0a94a4d4..b8b7a6fbd4 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -30,6 +30,7 @@ pub(crate) enum StatusSurfacePreviewItem { SessionId, FastMode, RawOutput, + WorkspaceHeadline, Model, ModelWithReasoning, Reasoning, @@ -62,6 +63,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::FastMode => "Fast on", StatusSurfacePreviewItem::RawOutput => "raw output", + StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::Reasoning => "medium", @@ -94,6 +96,7 @@ impl StatusSurfacePreviewItem { Self::SessionId, Self::FastMode, Self::RawOutput, + Self::WorkspaceHeadline, Self::Model, Self::ModelWithReasoning, Self::Reasoning, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd3c140e3f..aa7879f76d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -557,6 +557,7 @@ pub(crate) struct ChatWidget { next_token_activity_request_id: u64, pending_rate_limit_reset_request_id: Option, pending_rate_limit_reset_hint_request_id: Option, + pending_usage_menu_rate_limit_request_id: Option, pending_rate_limit_reset_hint: Option, available_rate_limit_reset_credits: Option, next_rate_limit_reset_request_id: u64, @@ -727,6 +728,16 @@ pub(crate) struct ChatWidget { status_line_git_summary_pending: bool, // True once we've attempted a Git summary lookup for the current CWD. status_line_git_summary_lookup_complete: bool, + // Cached workspace notification headline for the status line. + status_line_workspace_headline: Option, + // Request ID for the async workspace headline fetch currently in flight. + status_line_workspace_headline_pending_request_id: Option, + // Request ID to assign to the next workspace headline fetch. + next_status_line_workspace_headline_request_id: u64, + // Last time a workspace headline fetch was requested. + status_line_workspace_headline_last_requested_at: Option, + // Set after the backend reports the workspace-message feature gate is disabled. + status_line_workspace_messages_disabled: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, @@ -1188,6 +1199,7 @@ impl ChatWidget { { self.refresh_terminal_title(); } + self.refresh_status_line_if_workspace_headline_due(); } fn flush_active_cell(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 77fdd74e52..414a693bde 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -131,6 +131,7 @@ impl ChatWidget { next_token_activity_request_id: 0, pending_rate_limit_reset_request_id: None, pending_rate_limit_reset_hint_request_id: None, + pending_usage_menu_rate_limit_request_id: None, pending_rate_limit_reset_hint: None, available_rate_limit_reset_credits: None, next_rate_limit_reset_request_id: 0, @@ -229,6 +230,11 @@ impl ChatWidget { status_line_git_summary_cwd: None, status_line_git_summary_pending: false, status_line_git_summary_lookup_complete: false, + status_line_workspace_headline: None, + status_line_workspace_headline_pending_request_id: None, + next_status_line_workspace_headline_request_id: 0, + status_line_workspace_headline_last_requested_at: None, + status_line_workspace_messages_disabled: false, current_goal_status_indicator: None, current_goal_status: None, external_editor_state: ExternalEditorState::Closed, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index b24a4fde27..916c54cd5e 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -24,8 +24,9 @@ impl ChatWidget { { return; } - let should_submit_now = - self.is_session_configured() && !self.is_plan_streaming_in_tui(); + let should_submit_now = self.is_session_configured() + && !self.is_plan_streaming_in_tui() + && !self.input_queue.suppress_queue_autosend; if should_submit_now { if self.only_user_shell_commands_running() && !user_message.text.starts_with('!') @@ -69,6 +70,20 @@ impl ChatWidget { self.refresh_plan_mode_nudge(); } + pub(super) fn defer_input_until_settings_applied(&mut self) { + if !self.bottom_pane.no_modal_or_popup_active() { + self.input_queue.suppress_queue_autosend = true; + } + } + + pub(super) fn on_modal_or_popup_closed(&mut self) { + if self.input_queue.suppress_queue_autosend { + self.app_event_tx.send(AppEvent::SettingsSelectionClosed); + } else { + self.maybe_send_next_queued_input(); + } + } + pub(super) fn queue_user_message(&mut self, user_message: UserMessage) { self.queue_user_message_with_options(user_message, QueuedInputAction::Plain, Vec::new()); } @@ -84,7 +99,10 @@ impl ChatWidget { action: QueuedInputAction, pending_pastes: Vec<(String, String)>, ) { - if !self.is_session_configured() || self.is_user_turn_pending_or_running() { + if !self.is_session_configured() + || self.is_user_turn_pending_or_running() + || self.input_queue.suppress_queue_autosend + { self.input_queue .queued_user_messages .push_back(QueuedUserMessage { diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 74fc3d5e52..ba0aeb0a91 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -25,7 +25,7 @@ impl ChatWidget { self.pause_active_goal_for_interrupt(); } if self.bottom_pane.no_modal_or_popup_active() { - self.maybe_send_next_queued_input(); + self.on_modal_or_popup_closed(); } return; } @@ -392,6 +392,9 @@ impl ChatWidget { if should_pause_active_goal { self.pause_active_goal_for_interrupt(); } + if modal_or_popup_active && self.bottom_pane.no_modal_or_popup_active() { + self.on_modal_or_popup_closed(); + } return; } diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index c9cb3b108f..c97721819e 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; impl ChatWidget { pub(super) fn open_permission_profiles_popup(&mut self) { @@ -48,7 +49,7 @@ impl ChatWidget { } items.push(self.builtin_permission_mode_selection_item( full_access, - ":danger-no-sandbox", + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, full_access.description.to_string(), AskForApproval::from(full_access.approval), ApprovalsReviewer::User, @@ -73,6 +74,7 @@ impl ChatWidget { .as_deref() .unwrap_or("Configured permission profile."), active_profile_id.as_deref(), + profile.allowed, ) }), ); @@ -141,6 +143,12 @@ impl ChatWidget { .can_set_permission_profile(&preset.permission_profile) .err() .map(|err| err.to_string()) + }) + .or_else(|| { + (!self + .config + .is_permission_profile_allowed(id, &preset.permission_profile)) + .then(|| "Disabled by requirements.".to_string()) }), ..Default::default() } @@ -151,6 +159,7 @@ impl ChatWidget { id: &str, description: &str, active_profile_id: Option<&str>, + allowed: bool, ) -> SelectionItem { let id_for_action = id.to_string(); let selection = PermissionProfileSelection { @@ -165,6 +174,7 @@ impl ChatWidget { is_current: active_profile_id == Some(id), actions: Self::permission_profile_selection_actions(selection), dismiss_on_select: true, + disabled_reason: (!allowed).then(|| "Disabled by requirements.".to_string()), ..Default::default() } } diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index c51e2e8ea9..a19ede3ab5 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::Path; use std::time::Duration; use std::time::Instant; @@ -9,6 +10,7 @@ use super::plugins::PLUGINS_SELECTION_VIEW_ID; use super::plugins::PluginsCacheState; use crate::app_event::AppEvent; use crate::app_event::PluginLocation; +use crate::app_event::PluginRemoteSectionError; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -24,18 +26,24 @@ use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; +use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSharePrincipal; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_core_plugins::is_openai_curated_marketplace_name; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -55,6 +63,179 @@ const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; +const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; +const REMOTE_LOADING_TAB_ID_PREFIX: &str = "remote-loading:"; +const REMOTE_EMPTY_TAB_ID_PREFIX: &str = "remote-empty:"; +const REMOTE_ERROR_TAB_ID_PREFIX: &str = "remote-error:"; +const WORKSPACE_SECTION_TAB_ORDER: u8 = 0; +const SHARED_WITH_ME_SECTION_TAB_ORDER: u8 = 1; +const SHARED_WITH_ME_LINK_SECTION_TAB_ORDER: u8 = 2; +const LOCAL_MARKETPLACE_TAB_ORDER: u8 = 3; +const OTHER_MARKETPLACE_TAB_ORDER: u8 = 4; + +#[derive(Debug, Clone)] +struct PreferredLocalPluginSource { + marketplace_path: AbsolutePathBuf, + plugin_name: String, + installed: bool, +} + +#[derive(Debug, Clone, Copy)] +enum MarketplaceProduct { + OpenAiCurated, + Workspace, + SharedWithMe, + SharedWithMeLink, + Local, + Other, +} + +impl MarketplaceProduct { + fn from_marketplace(marketplace: &PluginMarketplaceEntry) -> Self { + Self::from_marketplace_parts(&marketplace.name, marketplace.path.as_ref()) + } + + fn from_marketplace_parts( + marketplace_name: &str, + marketplace_path: Option<&AbsolutePathBuf>, + ) -> Self { + if marketplace_path.is_some_and(is_personal_marketplace_path) { + return Self::Local; + } + + Self::from_marketplace_name(marketplace_name) + } + + fn from_marketplace_name(marketplace_name: &str) -> Self { + if is_openai_curated_marketplace_name(marketplace_name) + || marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME + { + return Self::OpenAiCurated; + } + + match marketplace_name { + REMOTE_WORKSPACE_MARKETPLACE_NAME => Self::Workspace, + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME => Self::SharedWithMe, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Self::SharedWithMeLink, + _ => Self::Other, + } + } + + fn label(self) -> Option<&'static str> { + match self { + Self::OpenAiCurated => Some("OpenAI Curated"), + Self::Workspace => Some("Workspace"), + Self::SharedWithMe => Some("Shared with me"), + Self::SharedWithMeLink => Some("Shared with me (link)"), + Self::Local => Some("Local"), + Self::Other => None, + } + } + + fn tab_order(self) -> u8 { + match self { + Self::Workspace => WORKSPACE_SECTION_TAB_ORDER, + Self::SharedWithMe => SHARED_WITH_ME_SECTION_TAB_ORDER, + Self::SharedWithMeLink => SHARED_WITH_ME_LINK_SECTION_TAB_ORDER, + Self::Local => LOCAL_MARKETPLACE_TAB_ORDER, + Self::OpenAiCurated | Self::Other => OTHER_MARKETPLACE_TAB_ORDER, + } + } + + fn is_by_openai(self) -> bool { + matches!(self, Self::OpenAiCurated) + } +} + +#[derive(Debug, Clone, Copy)] +struct RemoteMarketplaceSection { + id: &'static str, + label: &'static str, + loading_tab_id: &'static str, + marketplace_names: &'static [&'static str], + empty_item_name: &'static str, + empty_item_description: &'static str, + tab_order: u8, +} + +const REMOTE_MARKETPLACE_SECTIONS: [RemoteMarketplaceSection; 2] = [ + RemoteMarketplaceSection { + id: "workspace", + label: "Workspace", + loading_tab_id: "workspace-loading", + marketplace_names: &[REMOTE_WORKSPACE_MARKETPLACE_NAME], + empty_item_name: "No workspace plugins available", + empty_item_description: "No workspace directory plugins are available.", + tab_order: WORKSPACE_SECTION_TAB_ORDER, + }, + RemoteMarketplaceSection { + id: "shared-with-me", + label: "Shared with me", + loading_tab_id: "shared-with-me-loading", + marketplace_names: &[ + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + ], + empty_item_name: "No shared plugins available", + empty_item_description: "No plugins have been shared with you.", + tab_order: SHARED_WITH_ME_SECTION_TAB_ORDER, + }, +]; + +impl RemoteMarketplaceSection { + fn fallback_tab( + self, + marketplaces: &[PluginMarketplaceEntry], + remote_sections_loading: bool, + remote_sections_loaded: bool, + section_errors: &[PluginRemoteSectionError], + ) -> Option<(u8, SelectionTab)> { + if marketplaces + .iter() + .any(|marketplace| self.contains_marketplace(&marketplace.name)) + { + return None; + } + + let tab = if remote_sections_loading { + remote_section_loading_tab(self.loading_tab_id, self.label) + } else if remote_sections_loaded { + if let Some(section_error) = plugin_remote_section_error(section_errors, self.id) { + remote_section_error_tab(section_error) + } else { + remote_section_empty_tab( + self.id, + self.label, + self.empty_item_name, + self.empty_item_description, + ) + } + } else { + return None; + }; + + Some((self.tab_order, tab)) + } + + fn contains_marketplace(self, marketplace_name: &str) -> bool { + self.marketplace_names.contains(&marketplace_name) + } + + fn is_fallback_tab_id(self, tab_id: &str) -> bool { + tab_id.strip_prefix(REMOTE_LOADING_TAB_ID_PREFIX) == Some(self.loading_tab_id) + || tab_id.strip_prefix(REMOTE_EMPTY_TAB_ID_PREFIX) == Some(self.id) + || tab_id.strip_prefix(REMOTE_ERROR_TAB_ID_PREFIX) == Some(self.id) + } + + fn contains_tab_id(self, tab_id: &str) -> bool { + self.is_fallback_tab_id(tab_id) + || tab_id + .strip_prefix(MARKETPLACE_TAB_ID_PREFIX) + .is_some_and(|marketplace_name| self.contains_marketplace(marketplace_name)) + } +} struct DelayedLoadingHeader { started_at: Instant, @@ -237,18 +418,18 @@ impl ChatWidget { description: Some("Keep this marketplace installed.".to_string()), selected_description: Some("Keep this marketplace installed.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_cancel.clone(), - result: Ok(plugins_response_for_cancel.clone()), + response: plugins_response_for_cancel.clone(), }); })], ..Default::default() }, ], on_cancel: Some(Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_on_cancel.clone(), - result: Ok(plugins_response_for_on_cancel.clone()), + response: plugins_response_for_on_cancel.clone(), }); })), ..Default::default() @@ -421,9 +602,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -478,9 +659,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -518,9 +699,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -542,19 +723,15 @@ impl ChatWidget { active_tab_id: Option, initial_selected_idx: Option, ) -> SelectionViewParams { - let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); + let marketplaces = &response.marketplaces; + let preferred_local_sources = preferred_local_plugin_sources(marketplaces); - let total: usize = marketplaces + let all_entries = plugin_entries_for_marketplaces(marketplaces); + let total = all_entries.len(); + let installed = all_entries .iter() - .map(|marketplace| marketplace.plugins.len()) - .sum(); - let installed = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .filter(|plugin| plugin.installed) + .filter(|(_, plugin, _)| plugin.installed) .count(); - - let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); let name_column_width = all_entries .iter() .map(|(_, _, display_name)| { @@ -570,6 +747,14 @@ impl ChatWidget { let mut tabs = Vec::new(); let mut tab_footer_hints = Vec::new(); + let all_items = self.plugin_selection_items( + all_entries, + &preferred_local_sources, + /*include_marketplace_names*/ true, + "No marketplace plugins available", + "No plugins are available in the discovered marketplaces.", + ); + tabs.push(SelectionTab { id: ALL_PLUGINS_TAB_ID.to_string(), label: "All Plugins".to_string(), @@ -577,12 +762,7 @@ impl ChatWidget { "Browse plugins from available marketplaces.".to_string(), format!("Installed {installed} of {total} available plugins."), ), - items: self.plugin_selection_items( - all_entries, - /*include_marketplace_names*/ true, - "No marketplace plugins available", - "No plugins are available in the discovered marketplaces.", - ), + items: all_items, }); tabs.push(SelectionTab { @@ -594,24 +774,61 @@ impl ChatWidget { ), items: self.plugin_selection_items( installed_entries, + &preferred_local_sources, /*include_marketplace_names*/ true, "No installed plugins", "No installed plugins.", ), }); - let curated_marketplace = marketplaces - .iter() - .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) - .copied(); - let curated_entries = curated_marketplace - .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) - .unwrap_or_default(); + let curated_entries = + plugin_entries_for_marketplaces(marketplaces.iter().filter(|marketplace| { + MarketplaceProduct::from_marketplace(marketplace).is_by_openai() + })); let curated_total = curated_entries.len(); let curated_installed = curated_entries .iter() .filter(|(_, plugin, _)| plugin.installed) .count(); + let curated_has_entries = !curated_entries.is_empty(); + let curated_loading = self.plugin_remote_sections_loading + && self.plugins_fetch_state.vertical_section_requested; + let by_openai_section_error = + plugin_remote_section_error(&self.plugin_remote_section_errors, "vertical"); + let (curated_empty_name, curated_empty_description) = + if curated_loading && !curated_has_entries { + ( + "Loading OpenAI Curated plugins...", + "This section updates when app-server returns it.", + ) + } else if let Some(section_error) = by_openai_section_error + && !curated_has_entries + { + ("OpenAI Curated unavailable", section_error.message.as_str()) + } else { + ( + "No OpenAI Curated plugins available", + "No OpenAI Curated plugins available.", + ) + }; + let mut curated_items = self.plugin_selection_items( + curated_entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + curated_empty_name, + curated_empty_description, + ); + if curated_loading && curated_has_entries { + curated_items.push(remote_section_loading_item("OpenAI Curated")); + } + if let Some(section_error) = by_openai_section_error + && curated_has_entries + { + curated_items.push(remote_section_error_item( + §ion_error.label, + §ion_error.message, + )); + } tabs.push(SelectionTab { id: OPENAI_CURATED_TAB_ID.to_string(), label: "OpenAI Curated".to_string(), @@ -619,27 +836,35 @@ impl ChatWidget { "OpenAI Curated marketplace.".to_string(), format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), ), - items: self.plugin_selection_items( - curated_entries, - /*include_marketplace_names*/ false, - "No OpenAI Curated plugins available", - "No OpenAI Curated plugins available.", - ), + items: curated_items, }); let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces .iter() - .copied() - .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) + .filter(|marketplace| !MarketplaceProduct::from_marketplace(marketplace).is_by_openai()) .collect(); - additional_marketplaces.sort_by(|left, right| { - marketplace_display_name(left) - .to_ascii_lowercase() - .cmp(&marketplace_display_name(right).to_ascii_lowercase()) - .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) - .then_with(|| left.name.cmp(&right.name)) + additional_marketplaces.sort_by_cached_key(|marketplace| { + let display_name = marketplace_display_name(marketplace); + ( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + display_name.to_ascii_lowercase(), + display_name, + marketplace.name.clone(), + ) }); + let mut additional_tabs = Vec::new(); + for section in REMOTE_MARKETPLACE_SECTIONS { + if let Some(fallback_tab) = section.fallback_tab( + marketplaces, + self.plugin_remote_sections_loading, + self.plugin_remote_sections_loaded, + &self.plugin_remote_section_errors, + ) { + additional_tabs.push(fallback_tab); + } + } + let labels = disambiguate_duplicate_tab_labels( additional_marketplaces .iter() @@ -681,20 +906,28 @@ impl ChatWidget { ), ) }; - tabs.push(SelectionTab { - id: tab_id, - label: label.clone(), - header, - items: self.plugin_selection_items( - entries, - /*include_marketplace_names*/ false, - "No plugins available in this marketplace", - "No plugins available in this marketplace.", - ), - }); + additional_tabs.push(( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + SelectionTab { + id: tab_id, + label: label.clone(), + header, + items: self.plugin_selection_items( + entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + "No plugins available in this marketplace", + "No plugins available in this marketplace.", + ), + }, + )); } + additional_tabs.sort_by_key(|(tab_order, _)| *tab_order); + tabs.extend(additional_tabs.into_iter().map(|(_, tab)| tab)); tabs.push(self.marketplace_add_tab()); + let initial_tab_id = + active_tab_id.and_then(|tab_id| plugin_tab_id_matching_saved_id(&tab_id, &tabs)); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), @@ -704,7 +937,7 @@ impl ChatWidget { )), tab_footer_hints, tabs, - initial_tab_id: active_tab_id, + initial_tab_id, is_searchable: true, search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, @@ -744,24 +977,15 @@ impl ChatWidget { plugins_response: &PluginListResponse, plugin: &PluginDetail, ) -> SelectionViewParams { - let marketplace_label = plugin.marketplace_name.clone(); + let marketplace_label = MarketplaceProduct::from_marketplace_parts( + &plugin.marketplace_name, + plugin.marketplace_path.as_ref(), + ) + .label() + .map(str::to_string) + .unwrap_or_else(|| plugin.marketplace_name.clone()); let display_name = plugin_display_name(&plugin.summary); - let detail_status_label = - if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - "Disabled by admin" - } else if plugin.summary.installed { - if plugin.summary.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.summary.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", - PluginInstallPolicy::InstalledByDefault => "Available by default", - } - }; + let detail_status_label = plugin_detail_status_label(&plugin.summary); let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -791,9 +1015,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -875,6 +1099,8 @@ impl ChatWidget { }); } + items.extend(plugin_metadata_items(plugin)); + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), @@ -913,6 +1139,7 @@ impl ChatWidget { fn plugin_selection_items<'a>( &self, mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, + preferred_local_sources: &HashMap, include_marketplace_names: bool, empty_name: &str, empty_description: &str, @@ -933,7 +1160,8 @@ impl ChatWidget { } else { plugin_brief_description_without_marketplace(plugin, status_label_width) }; - let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); + let plugin_detail_request = + plugin_detail_request_for_entry(marketplace, plugin, preferred_local_sources); let can_view_details = plugin_detail_request.is_some(); let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; let can_toggle_plugin = plugin.installed && !disabled_by_admin; @@ -1049,7 +1277,7 @@ pub(super) fn plugin_detail_hint_line() -> Line<'static> { Line::from("Press esc to close.") } -fn plugins_header(subtitle: String, count_line: String) -> Box { +pub(super) fn plugins_header(subtitle: String, count_line: String) -> Box { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from(subtitle.dim())); @@ -1057,10 +1285,225 @@ fn plugins_header(subtitle: String, count_line: String) -> Box { Box::new(header) } +fn dedupe_plugin_entries<'a>( + entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, +) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { + // App-server should eventually normalize local/remote duplicates. Keep this + // display-only pass narrow so shared plugins do not appear twice meanwhile. + let mut deduped: Vec<(&PluginMarketplaceEntry, &PluginSummary, String)> = Vec::new(); + let mut remote_entry_indexes = HashMap::new(); + for entry in entries { + let Some(remote_plugin_id) = plugin_remote_identity(entry.1) else { + deduped.push(entry); + continue; + }; + if let Some(existing_index) = remote_entry_indexes.get(&remote_plugin_id).copied() { + if plugin_entry_preferred(&entry, &deduped[existing_index]) { + deduped[existing_index] = entry; + } + } else { + remote_entry_indexes.insert(remote_plugin_id, deduped.len()); + deduped.push(entry); + } + } + deduped +} + +fn plugin_entry_preferred( + candidate: &(&PluginMarketplaceEntry, &PluginSummary, String), + existing: &(&PluginMarketplaceEntry, &PluginSummary, String), +) -> bool { + if candidate.1.installed != existing.1.installed { + return candidate.1.installed; + } + + let candidate_is_local_share = + candidate.1.share_context.is_some() && !matches!(&candidate.1.source, PluginSource::Remote); + let existing_is_local_share = + existing.1.share_context.is_some() && !matches!(&existing.1.source, PluginSource::Remote); + if candidate_is_local_share != existing_is_local_share { + return candidate_is_local_share; + } + + !matches!(&candidate.1.source, PluginSource::Remote) + && matches!(&existing.1.source, PluginSource::Remote) +} + +fn preferred_local_plugin_sources( + marketplaces: &[PluginMarketplaceEntry], +) -> HashMap { + let mut sources = HashMap::new(); + for marketplace in marketplaces { + let Some(marketplace_path) = marketplace.path.as_ref() else { + continue; + }; + for plugin in &marketplace.plugins { + if matches!(&plugin.source, PluginSource::Remote) { + continue; + } + let Some(share_context) = plugin.share_context.as_ref() else { + continue; + }; + sources + .entry(share_context.remote_plugin_id.clone()) + .or_insert_with(|| PreferredLocalPluginSource { + marketplace_path: marketplace_path.clone(), + plugin_name: plugin.name.clone(), + installed: plugin.installed, + }); + } + } + sources +} + +fn plugin_detail_status_label(plugin: &PluginSummary) -> &'static str { + if plugin.availability == PluginAvailability::DisabledByAdmin { + return "Disabled by admin"; + } + if plugin.installed { + if plugin.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + } +} + +fn plugin_metadata_items(plugin: &PluginDetail) -> Vec { + let mut items = Vec::new(); + items.push(SelectionItem { + name: "Source".to_string(), + description: Some(plugin_source_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Auth".to_string(), + description: Some(plugin_auth_policy_summary(plugin.summary.auth_policy)), + is_disabled: true, + ..Default::default() + }); + if let Some(version) = plugin_version_summary(&plugin.summary) { + items.push(SelectionItem { + name: "Version".to_string(), + description: Some(version), + is_disabled: true, + ..Default::default() + }); + } + if let Some(share_context) = &plugin.summary.share_context { + items.push(SelectionItem { + name: "Sharing".to_string(), + description: Some(plugin_share_context_summary(share_context)), + is_disabled: true, + ..Default::default() + }); + } + items +} + +fn plugin_source_summary(plugin: &PluginDetail) -> String { + match &plugin.summary.source { + PluginSource::Local { .. } => "Local".to_string(), + PluginSource::Git { url, ref_name, .. } => match ref_name { + Some(ref_name) => format!("Git · {url}@{ref_name}"), + None => format!("Git · {url}"), + }, + PluginSource::Remote => { + let marketplace_label = + MarketplaceProduct::from_marketplace_name(&plugin.marketplace_name) + .label() + .unwrap_or(plugin.marketplace_name.as_str()); + format!("Remote · {marketplace_label}") + } + } +} + +fn plugin_auth_policy_summary(auth_policy: PluginAuthPolicy) -> String { + match auth_policy { + PluginAuthPolicy::OnInstall => "Auth on install".to_string(), + PluginAuthPolicy::OnUse => "Auth on use".to_string(), + } +} + +fn plugin_version_summary(plugin: &PluginSummary) -> Option { + let mut parts = Vec::new(); + if let Some(local_version) = plugin.local_version.as_deref() { + parts.push(format!("local {local_version}")); + } + if let Some(remote_version) = plugin + .share_context + .as_ref() + .and_then(|context| context.remote_version.as_deref()) + { + parts.push(format!("remote {remote_version}")); + } + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +fn plugin_share_context_summary(context: &PluginShareContext) -> String { + let mut parts = Vec::new(); + if let Some(discoverability) = context.discoverability { + parts.push(plugin_share_discoverability_label(discoverability).to_string()); + } + if let Some(creator_summary) = plugin_share_creator_summary(context) { + parts.push(creator_summary); + } + if let Some(principals) = context.share_principals.as_ref() { + parts.push(plugin_share_principals_summary(principals)); + } + if let Some(share_url) = context + .share_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + { + parts.push(share_url.to_string()); + } + if parts.is_empty() { + format!("Remote ID {}", context.remote_plugin_id) + } else { + parts.join(" · ") + } +} + +fn plugin_share_discoverability_label(discoverability: PluginShareDiscoverability) -> &'static str { + match discoverability { + PluginShareDiscoverability::Listed => "Listed", + PluginShareDiscoverability::Unlisted => "Workspace link", + PluginShareDiscoverability::Private => "Private", + } +} + +fn plugin_share_creator_summary(context: &PluginShareContext) -> Option { + match ( + context.creator_name.as_deref(), + context.creator_account_user_id.as_deref(), + ) { + (Some(name), Some(account_id)) => Some(format!("creator {name} ({account_id})")), + (Some(name), None) => Some(format!("creator {name}")), + (None, Some(account_id)) => Some(format!("creator account {account_id}")), + (None, None) => None, + } +} + +fn plugin_share_principals_summary(principals: &[PluginSharePrincipal]) -> String { + match principals.len() { + 0 => "No explicit principals".to_string(), + 1 => format!("1 principal: {}", principals[0].name), + count => format!("{count} principals"), + } +} + fn plugin_entries_for_marketplaces<'a>( marketplaces: impl IntoIterator, ) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { - marketplaces + let entries = marketplaces .into_iter() .flat_map(|marketplace| { marketplace @@ -1068,7 +1511,8 @@ fn plugin_entries_for_marketplaces<'a>( .iter() .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) }) - .collect() + .collect::>(); + dedupe_plugin_entries(entries) } fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { @@ -1103,6 +1547,10 @@ pub(super) fn marketplace_tab_id_matching_saved_id( saved_tab_id: &str, marketplaces: &[PluginMarketplaceEntry], ) -> Option { + if let Some(tab_id) = remote_section_marketplace_tab_id(saved_tab_id, marketplaces) { + return Some(tab_id); + } + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { let tab_id = marketplace_tab_id(marketplace); (tab_id == saved_tab_id).then_some(tab_id) @@ -1124,6 +1572,43 @@ pub(super) fn marketplace_tab_id_matching_saved_id( }) } +fn remote_section_marketplace_tab_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.is_fallback_tab_id(saved_tab_id))?; + + section + .marketplace_names + .iter() + .find_map(|marketplace_name| { + marketplaces + .iter() + .find(|marketplace| marketplace.name.as_str() == *marketplace_name) + .map(marketplace_tab_id) + }) +} + +fn plugin_tab_id_matching_saved_id(saved_tab_id: &str, tabs: &[SelectionTab]) -> Option { + if let Some(tab_id) = tabs + .iter() + .find(|tab| tab.id.as_str() == saved_tab_id) + .map(|tab| tab.id.clone()) + { + return Some(tab_id); + } + + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.contains_tab_id(saved_tab_id))?; + + tabs.iter() + .find(|tab| section.contains_tab_id(&tab.id)) + .map(|tab| tab.id.clone()) +} + pub(super) fn merge_remote_marketplaces( response: &mut PluginListResponse, remote_marketplaces: Vec, @@ -1132,62 +1617,134 @@ pub(super) fn merge_remote_marketplaces( .iter() .map(|marketplace| marketplace.name.clone()) .collect::>(); + let remote_curated_present = remote_names.contains(REMOTE_GLOBAL_MARKETPLACE_NAME); response.marketplaces.retain(|marketplace| { + if remote_curated_present + && marketplace.path.is_some() + && is_openai_curated_marketplace_name(&marketplace.name) + { + return false; + } + marketplace.path.is_some() - || !remote_marketplace_is_remote_section(marketplace) + || !REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .any(|section| section.contains_marketplace(&marketplace.name)) && !remote_names.contains(marketplace.name.as_str()) }); response.marketplaces.extend(remote_marketplaces); } -fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { - matches!( - marketplace.name.as_str(), - REMOTE_WORKSPACE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME - ) +fn is_personal_marketplace_path(marketplace_path: &AbsolutePathBuf) -> bool { + dirs::home_dir() + .and_then(|home| { + AbsolutePathBuf::try_from(home.join(PERSONAL_MARKETPLACE_RELATIVE_PATH)).ok() + }) + .is_some_and(|personal_path| personal_path.as_path() == marketplace_path.as_path()) +} + +fn remote_section_loading_item(label: &str) -> SelectionItem { + SelectionItem { + name: format!("Loading {label} plugins..."), + description: Some("This section updates when app-server returns it.".to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn remote_section_error_item(label: &str, message: &str) -> SelectionItem { + SelectionItem { + name: format!("{label} unavailable"), + description: Some(message.to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn plugin_remote_section_error<'a>( + section_errors: &'a [PluginRemoteSectionError], + section_id: &str, +) -> Option<&'a PluginRemoteSectionError> { + section_errors + .iter() + .find(|section_error| section_error.section_id == section_id) +} + +fn remote_section_loading_tab(id: &str, label: &str) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_LOADING_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("Loading {label} plugins."), + "Local plugin functionality is already available.".to_string(), + ), + items: vec![remote_section_loading_item(label)], + } +} + +fn remote_section_empty_tab( + id: &str, + label: &str, + item_name: &str, + item_description: &str, +) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_EMPTY_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("{label}."), + "This section loaded successfully.".to_string(), + ), + items: vec![SelectionItem { + name: item_name.to_string(), + description: Some(item_description.to_string()), + is_disabled: true, + ..Default::default() + }], + } +} + +fn remote_section_error_tab(section_error: &PluginRemoteSectionError) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_ERROR_TAB_ID_PREFIX}{}", section_error.section_id), + label: section_error.label.clone(), + header: plugins_header( + format!("{} unavailable.", section_error.label), + "Local plugin functionality is still available.".to_string(), + ), + items: vec![remote_section_error_item( + §ion_error.label, + §ion_error.message, + )], + } } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { - let mut counts: Vec<(String, usize)> = Vec::new(); + let mut counts = HashMap::new(); for label in &labels { - if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { - *count += 1; - } else { - counts.push((label.clone(), 1)); - } + *counts.entry(label.clone()).or_insert(0) += 1; } - let mut seen: Vec<(String, usize)> = Vec::new(); + let mut seen = HashMap::new(); labels .into_iter() .map(|label| { - let total = counts - .iter() - .find(|(existing, _)| existing == &label) - .map(|(_, count)| *count) - .unwrap_or(1); + let total = counts[&label]; if total == 1 { return label; } - let current = if let Some((_, seen_count)) = - seen.iter_mut().find(|(existing, _)| existing == &label) - { - *seen_count += 1; - *seen_count - } else { - seen.push((label.clone(), 1)); - 1 - }; + let current = seen.entry(label.clone()).or_insert(0); + *current += 1; format!("{label} ({current}/{total})") }) .collect() } pub(super) fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { + if let Some(label) = MarketplaceProduct::from_marketplace(marketplace).label() { + return label.to_string(); + } marketplace .interface .as_ref() @@ -1271,7 +1828,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available", + PluginInstallPolicy::InstalledByDefault => "Available by default", } } } @@ -1300,7 +1857,21 @@ fn plugin_detail_location(plugin: &PluginDetail) -> Option { fn plugin_detail_request_for_entry( marketplace: &PluginMarketplaceEntry, plugin: &PluginSummary, + preferred_local_sources: &HashMap, ) -> Option<(PluginLocation, String)> { + if matches!(&plugin.source, PluginSource::Remote) + && let Some(remote_plugin_id) = plugin_remote_identity(plugin) + && let Some(preferred_source) = preferred_local_sources.get(remote_plugin_id) + && preferred_source.installed == plugin.installed + { + return Some(( + PluginLocation::Local { + marketplace_path: preferred_source.marketplace_path.clone(), + }, + preferred_source.plugin_name.clone(), + )); + } + plugin_location_for_marketplace(marketplace, plugin) .map(|location| (location, plugin_request_name(plugin))) } @@ -1309,22 +1880,22 @@ fn plugin_request_name(plugin: &PluginSummary) -> String { if matches!(&plugin.source, PluginSource::Remote) && let Some(remote_plugin_id) = plugin_remote_identity(plugin) { - return remote_plugin_id; + return remote_plugin_id.to_string(); } plugin.name.clone() } -fn plugin_remote_identity(plugin: &PluginSummary) -> Option { +fn plugin_remote_identity(plugin: &PluginSummary) -> Option<&str> { plugin .share_context .as_ref() - .map(|context| context.remote_plugin_id.clone()) - .or_else(|| plugin.remote_plugin_id.clone()) + .map(|context| context.remote_plugin_id.as_str()) + .or(plugin.remote_plugin_id.as_deref()) } fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { if matches!(&plugin.source, PluginSource::Remote) { - return plugin_remote_identity(plugin); + return plugin_remote_identity(plugin).map(str::to_string); } Some(plugin.id.clone()) } diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 8c259856fb..0ff8333ffc 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -42,6 +42,7 @@ pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; pub(super) struct PluginListFetchState { pub(super) cache_cwd: Option, pub(super) in_flight_cwd: Option, + pub(super) vertical_section_requested: bool, } #[derive(Debug, Clone)] @@ -147,6 +148,7 @@ impl ChatWidget { Err(err) => { self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = false; + self.plugins_fetch_state.vertical_section_requested = false; if should_refresh_plugins_popup { self.plugins_fetch_state.cache_cwd = None; self.plugins_cache = PluginsCacheState::Failed(err.clone()); @@ -175,6 +177,7 @@ impl ChatWidget { .is_some(); self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = true; + self.plugins_fetch_state.vertical_section_requested = false; let refreshed_response = match &mut self.plugins_cache { PluginsCacheState::Ready(response) if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => @@ -212,6 +215,8 @@ impl ChatWidget { } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); + self.plugins_fetch_state.vertical_section_requested = + !self.config.features.enabled(Feature::RemotePlugin); if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -245,6 +250,36 @@ impl ChatWidget { )); } + pub(crate) fn open_plugins_list(&mut self, cwd: PathBuf, response: PluginListResponse) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + let response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(current_response) => current_response, + PluginsCacheState::Uninitialized + | PluginsCacheState::Loading + | PluginsCacheState::Failed(_) => response, + }; + self.plugins_fetch_state.cache_cwd = Some(cwd); + self.plugins_cache = PluginsCacheState::Ready(response.clone()); + let active_tab_id = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .map(str::to_string) + .or_else(|| self.plugins_active_tab_id.clone()) + .or_else(|| Some(ALL_PLUGINS_TAB_ID.to_string())); + self.plugins_active_tab_id = active_tab_id.clone(); + let params = + self.plugins_popup_params(&response, active_tab_id, /*initial_selected_idx*/ None); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.open_plugins_popup(&response); + } + } + pub(crate) fn open_marketplace_add_prompt(&mut self) { self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); let tx = self.app_event_tx.clone(); diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index e2cf31b034..085255a1cf 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -208,6 +208,7 @@ impl ChatWidget { | ServerNotification::ExternalAgentConfigImportProgress(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::ModelSafetyBufferingUpdated(_) | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index efb0b63628..77a0c5c7ec 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -5,6 +5,7 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; +pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt"; const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage"; @@ -399,6 +400,7 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID), title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75f8d1ad4b..269606098f 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -2,6 +2,7 @@ use super::*; use crate::app_event::AppEvent; +use crate::chatwidget::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID; impl ChatWidget { /// Set the approval policy in the widget's config copy. @@ -217,13 +218,27 @@ impl ChatWidget { has_chatgpt_account: bool, has_codex_backend_auth: bool, ) { - let account_state_changed = self.status_account_display != status_account_display - || self.has_chatgpt_account != has_chatgpt_account - || self.has_codex_backend_auth != has_codex_backend_auth; - if account_state_changed { - self.clear_pending_token_activity_refreshes(); - self.clear_pending_rate_limit_reset_requests(); + // Account-update notifications are the identity boundary. The visible account fields can + // be identical across two accounts, so always invalidate account-scoped requests and data. + self.clear_pending_token_activity_refreshes(); + self.clear_pending_rate_limit_reset_requests(); + self.codex_rate_limit_reached_type = None; + self.rate_limit_warnings = RateLimitWarningState::default(); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID); + let had_refreshing_status_outputs = !self.refreshing_status_outputs.is_empty(); + let now = Local::now(); + for (_, handle) in self.refreshing_status_outputs.drain(..) { + handle.finish_rate_limit_refresh(&[], now); } + if had_refreshing_status_outputs { + self.request_redraw(); + } + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; @@ -232,6 +247,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); + self.refresh_status_surfaces(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 7c1179681e..df9c65f8ce 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -131,6 +131,13 @@ impl ChatWidget { .send(AppEvent::RawOutputModeChanged { enabled }); } + fn slash_command_blocked_by_active_task(&self, cmd: SlashCommand) -> bool { + (!cmd.available_during_task() && self.bottom_pane.is_task_running()) + || (cmd == SlashCommand::Resume + && (self.input_queue.user_turn_pending_start + || self.turn_lifecycle.agent_turn_running)) + } + pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) { if !self.ensure_slash_command_allowed_in_side_conversation(cmd) { return; @@ -138,7 +145,7 @@ impl ChatWidget { if !self.ensure_side_command_allowed_outside_review(cmd) { return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if self.slash_command_blocked_by_active_task(cmd) { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() @@ -263,9 +270,11 @@ impl ChatWidget { } SlashCommand::Model => { self.open_model_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Personality => { self.open_personality_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Plan => { self.apply_plan_slash_command(); @@ -293,6 +302,7 @@ impl ChatWidget { } SlashCommand::Permissions => { self.open_permissions_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Vim => { self.toggle_vim_mode_and_notify(); @@ -545,7 +555,7 @@ impl ChatWidget { self.dispatch_command(cmd); return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if self.slash_command_blocked_by_active_task(cmd) { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap index e8f08a437a..4b4a02e0be 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: blob --- -■ '/model' is disabled while a task is in progress. +■ '/resume' is disabled while a task is in progress. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index c7edae1714..e5d14a4c51 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,13 +3,15 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: strip_osc8_for_snapshot(&popup) --- Plugins - Figma · Can be installed · ChatGPT Marketplace + Figma · Can be installed · Local Data shared with this app is subject to the app's terms of service and privacy policy. Learn more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 4d91b17f34..2b69864cc5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -8,6 +8,8 @@ expression: strip_osc8_for_snapshot(&popup) › 1. Back to plugins Return to the plugin list. 2. Uninstall plugin Remove this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap new file mode 100644 index 0000000000..31ff7ba462 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) +--- + Plugins + Docs · Can be installed · Local + Data shared with this app is subject to the app's terms of service and privacy policy. Learn more. + Workspace docs. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + Source Local + Auth Auth on install + Version remote 7 + Sharing Private · creator Test User · https://chatgpt.com/codex/plugins/share/docs + Skills No plugin skills. + Hooks No plugin hooks. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 40d26d3c9d..2ec91937fb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -6,12 +6,12 @@ expression: popup Browse plugins from available marketplaces. Installed 1 of 4 available plugins. - [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace + [All Plugins] Installed (1) OpenAI Curated Workspace Shared with me Repo Marketplace Add Marketplace Type to search plugins -› [ ] Alpha Sync Disabled Space to enable; Enter view details. - [-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. - [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. - [-] Starter Available · ChatGPT Marketplace · Included by default. +› [ ] Alpha Sync Disabled Space to enable; Enter view details. + [-] Bravo Search Available · OpenAI Curated · Search docs and tickets. + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. + [-] Starter Available by default · OpenAI Curated · Included by default. space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap index 515b700925..803ff9fbe6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap @@ -1,13 +1,13 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -assertion_line: 339 expression: popup --- Plugins Debug Marketplace installed successfully. Select the plugins you want to use and press Enter to install or view details. - All Plugins Installed (0) OpenAI Curated [Debug Marketplace] Add Marketplace + All Plugins Installed (0) OpenAI Curated Workspace Shared with me [Debug Marketplace] + Add Marketplace Type to search plugins › [-] Debug Plugin Available Press Enter to install or view plugin details. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index 849b90b2ce..bfb0affb21 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -7,7 +7,7 @@ expression: popup Browse plugins from available marketplaces. Installed 0 of 3 available plugins. - [All Plugins] Installed (0) OpenAI Curated Add Marketplace + [All Plugins] Installed (0) OpenAI Curated Workspace Shared with me Add Marketplace sla › [-] Slack Available Press Enter to install or view plugin details. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap index dac63d4e32..008669d3d3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap @@ -4,18 +4,19 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions - 1. Ask for approval Codex can read and edit files in the current - workspace, and run commands. Approval is required - to access the internet or edit other files. - 2. Approve for me Only ask for actions detected as potentially - unsafe. - 3. Full Access Codex can edit files outside this workspace and - access the internet without asking for approval. - Exercise caution when using. - 4. Read Only Codex can read files in the current workspace. - Approval is required to edit files or access the - internet. -› 5. locked-down (current) Inspect and patch only approved workspace files. - 6. web-enabled Workspace profile with network access. + 1. Ask for approval Codex can read and edit files in the current + workspace, and run commands. Approval is required + to access the internet or edit other files. + 2. Approve for me Only ask for actions detected as potentially + unsafe. + 3. Full Access Codex can edit files outside this workspace and + access the internet without asking for approval. + Exercise caution when using. + 4. Read Only Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. +› 5. locked-down (current) Inspect and patch only approved workspace files. + web-enabled (disabled) Workspace profile with network access. (disabled: + Disabled by requirements.) Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap new file mode 100644 index 0000000000..1d2a74e956 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Update Model Permissions + + 1. Ask for approval Codex can read and edit files in the current + workspace, and run commands. Approval is required + to access the internet or edit other files. + 2. Approve for me Only ask for actions detected as potentially + unsafe. + Full Access (disabled) Codex can edit files outside this workspace and + access the internet without asking for approval. + Exercise caution when using. (disabled: Disabled + by requirements.) +› 3. Read Only (current) Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap index 5b528bb1df..56f0fc2f89 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests/usage.rs expression: rendered --- -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap index 9e332a9256..e18178d460 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap @@ -4,4 +4,4 @@ expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expec --- active tool -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap index ba6066e0bc..12b1568f55 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap @@ -2,41 +2,41 @@ source: tui/src/chatwidget/tests/usage.rs expression: "states.join(\"\\n---\\n\")" --- - Rate-limit resets + Usage limit resets Checking your available resets... › Loading... Press enter to confirm or esc to go back --- - Rate-limit resets - You have 2 rate-limit resets available. + Usage limit resets + You have 2 usage limit resets available. - 1. Use a reset Reset your current Codex usage windows. + 1. Use a reset Reset your current 5-hour and weekly usage limits. › 2. Cancel Press enter to confirm or esc to go back --- - Rate-limit resets - You don't have any rate-limit resets available. + Usage limit resets + You don't have any usage limit resets available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - Couldn't load rate-limit resets. Please try again. + Usage limit resets + Couldn't load usage limit resets. Please try again. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Resetting your usage... › Using a reset... --- - Rate-limit resets + Usage limit resets Couldn't reset usage. Please try again. › 1. Try again @@ -44,27 +44,27 @@ expression: "states.join(\"\\n---\\n\")" Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Your usage does not need a reset right now. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - No rate-limit resets are available. + Usage limit resets + No usage limit resets are available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Usage reset. Checking your remaining resets... › Refreshing... --- - Rate-limit resets - Usage reset. You have 1 rate-limit reset left. + Usage limit resets + Usage reset. You have 1 usage limit reset left. › 1. Close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap new file mode 100644 index 0000000000..32ad18ec65 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted … + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] current-dir Current working directory + [ ] project-name Project name (omitted when unavailable) + + Workspace maintenance starts at 5pm + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap index c3fc73a49f..70aa225aaf 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset You have 2 rate-limit resets available. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset You have 2 usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap index 6bfee11dea..d4dc5deff1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset Check reset availability. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap index 6bfee11dea..127e37f7a3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + Redeem usage limit reset No usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap new file mode 100644 index 0000000000..08a595d4a2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "states.join(\"\\n---\\n\")" +--- +Free: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Go: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Business with monthly window: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d4de738314..2c58be9370 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -247,11 +247,23 @@ impl ChatWidget { self.add_to_history(cell); } - pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { - if self.refreshing_status_outputs.is_empty() { + pub(crate) fn finish_status_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + ) { + if !self + .refreshing_status_outputs + .iter() + .any(|(pending_request_id, _)| *pending_request_id == request_id) + { return; } + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + let rate_limit_snapshots: Vec = self .rate_limit_snapshots_by_limit_id .values() diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 592dd04381..5f02dd8d69 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -65,6 +65,11 @@ impl StatusSurfaceSelections { .status_line_items .contains(&StatusLineItem::BranchChanges) } + + fn uses_workspace_headline(&self) -> bool { + self.status_line_items + .contains(&StatusLineItem::WorkspaceHeadline) + } } /// Cached project-root display name keyed by the cwd used for the last lookup. @@ -157,6 +162,15 @@ impl ChatWidget { self.request_status_line_git_summary(cwd); } } + + if !selections.uses_workspace_headline() { + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; + } else { + self.request_status_line_workspace_headline_if_due(Instant::now()); + } } fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { @@ -553,6 +567,85 @@ impl ChatWidget { }); } + fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) { + if !self.status_line_workspace_headline_should_fetch(now) { + return; + } + let request_id = self.next_status_line_workspace_headline_request_id; + self.next_status_line_workspace_headline_request_id = self + .next_status_line_workspace_headline_request_id + .wrapping_add(/*rhs*/ 1); + self.status_line_workspace_headline_pending_request_id = Some(request_id); + self.status_line_workspace_headline_last_requested_at = Some(now); + self.app_event_tx + .send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }); + } + + fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { + if self + .status_line_workspace_headline_pending_request_id + .is_some() + || self.status_line_workspace_messages_disabled + || !self.has_codex_backend_auth + { + return false; + } + + self.status_line_workspace_headline_last_requested_at + .is_none_or(|last_requested_at| { + now.saturating_duration_since(last_requested_at) + >= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL + }) + } + + pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) { + let now = Instant::now(); + if self.status_line_workspace_headline_should_fetch(now) + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.refresh_status_line(); + } + } + + pub(crate) fn set_status_line_workspace_headline( + &mut self, + request_id: u64, + result: Result, + ) -> bool { + if self.status_line_workspace_headline_pending_request_id != Some(request_id) { + return false; + } + self.status_line_workspace_headline_pending_request_id = None; + match result { + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => { + self.status_line_workspace_messages_disabled = false; + self.status_line_workspace_headline = headline; + } + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => { + self.status_line_workspace_messages_disabled = true; + self.status_line_workspace_headline = None; + } + Err(err) => { + tracing::debug!(error = %err, "failed to fetch workspace headline"); + } + } + + if !self.status_line_workspace_messages_disabled + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.frame_requester + .schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL); + } + self.refresh_status_line(); + true + } + /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on @@ -653,6 +746,7 @@ impl ChatWidget { } }, ), + StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } } @@ -693,6 +787,7 @@ impl ChatWidget { StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, + StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e4196224dd..1af814eb76 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -244,6 +244,8 @@ mod history_replay; mod mcp_startup; mod permissions; mod plan_mode; +#[path = "tests/plugin_catalog_tests.rs"] +mod plugin_catalog; mod popups_and_settings; mod review_mode; mod side; diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 1b8a7beddb..4f7d225dd8 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1069,10 +1069,10 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() { async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.bottom_pane.set_task_running(/*running*/ true); + handle_turn_started(&mut chat, "turn-1"); - // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model); + // Resume remains available during MCP startup, but not while an agent turn is active. + chat.dispatch_command(SlashCommand::Resume); // Drain history and snapshot the rendered error line(s) let cells = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 9a50b37832..06c440bfb2 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1284,6 +1284,13 @@ pub(super) fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { .abs() } +pub(super) fn plugins_test_personal_marketplace_path() -> AbsolutePathBuf { + dirs::home_dir() + .expect("home directory should be available") + .join(".agents/plugins/marketplace.json") + .abs() +} + pub(super) fn plugins_test_interface( display_name: Option<&str>, short_description: Option<&str>, @@ -1304,7 +1311,9 @@ pub(super) fn plugins_test_interface( composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), } @@ -1370,6 +1379,21 @@ pub(super) fn plugins_test_remote_summary( } } +pub(super) fn plugins_test_remote_marketplace( + name: &str, + display_name: &str, + plugins: Vec, +) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: name.to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some(display_name.to_string()), + }), + plugins, + } +} + pub(super) fn plugins_test_curated_marketplace( plugins: Vec, ) -> PluginMarketplaceEntry { @@ -1406,11 +1430,23 @@ pub(super) fn plugins_test_response( pub(super) fn render_loaded_plugins_popup( chat: &mut ChatWidget, - response: PluginListResponse, + mut response: PluginListResponse, ) -> String { let cwd = chat.config.cwd.clone(); + let remote_marketplaces = response + .marketplaces + .iter() + .filter(|marketplace| marketplace.path.is_none()) + .cloned() + .collect(); + response + .marketplaces + .retain(|marketplace| marketplace.path.is_some()); + let response_for_refresh = response.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response_for_refresh)); + chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), remote_marketplaces, Vec::new()); render_bottom_popup(chat, /*width*/ 100) } @@ -1468,12 +1504,48 @@ pub(super) fn plugins_test_detail( } } +pub(super) fn plugins_test_remote_detail( + marketplace_name: &str, + summary: PluginSummary, + description: Option<&str>, +) -> PluginDetail { + PluginDetail { + marketplace_name: marketplace_name.to_string(), + marketplace_path: None, + summary, + share_url: None, + description: description.map(str::to_string), + skills: Vec::new(), + hooks: Vec::new(), + apps: Vec::new(), + app_templates: Vec::new(), + mcp_servers: Vec::new(), + } +} + pub(super) fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { popup .find(needle) .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) } +pub(super) fn select_plugins_tab_containing( + chat: &mut ChatWidget, + width: u16, + visible_text: &str, +) -> String { + for _ in 0..8 { + let popup = render_bottom_popup(chat, width); + if popup.contains(visible_text) { + return popup; + } + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(chat, width); + panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}"); +} + pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { for ch in query.chars() { chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a9f6f7abe1..ebac595671 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,6 +1,7 @@ use super::*; -use crate::legacy_core::config::CustomPermissionProfileSummary; +use crate::legacy_core::config::PermissionProfileCatalogEntry; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -57,6 +58,10 @@ fn windows_sandbox_requirements_stack( }), ..Default::default() }; + requirements_stack(requirements_toml) +} + +fn requirements_stack(requirements_toml: codex_config::ConfigRequirementsToml) -> ConfigLayerStack { let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default(); requirements_with_sources .merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone()); @@ -104,18 +109,40 @@ async fn profile_permissions_selection_popup_snapshot() { ); } +#[tokio::test] +async fn profile_permissions_selection_popup_with_disallowed_full_access_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.explicit_permission_profile_mode = true; + chat.config.config_layer_stack = requirements_stack(codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + codex_config::SandboxModeRequirement::ReadOnly, + codex_config::SandboxModeRequirement::WorkspaceWrite, + ]), + ..Default::default() + }); + + chat.open_permissions_popup(); + + assert_chatwidget_snapshot!( + "profile_permissions_selection_popup_with_disallowed_full_access", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + #[tokio::test] async fn profile_permissions_selection_popup_with_custom_profiles_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.explicit_permission_profile_mode = true; chat.config.custom_permission_profiles = vec![ - CustomPermissionProfileSummary { + PermissionProfileCatalogEntry { id: "locked-down".to_string(), description: Some("Inspect and patch only approved workspace files.".to_string()), + allowed: true, }, - CustomPermissionProfileSummary { + PermissionProfileCatalogEntry { id: "web-enabled".to_string(), description: Some("Workspace profile with network access.".to_string()), + allowed: false, }, ]; chat.config @@ -170,9 +197,10 @@ async fn profile_permissions_selection_emits_named_profile_event_only() { async fn profile_permissions_selection_emits_active_custom_profile() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.explicit_permission_profile_mode = true; - chat.config.custom_permission_profiles = vec![CustomPermissionProfileSummary { + chat.config.custom_permission_profiles = vec![PermissionProfileCatalogEntry { id: "locked-down".to_string(), description: None, + allowed: true, }]; chat.config .permissions @@ -256,7 +284,7 @@ async fn profile_permissions_full_access_opens_confirmation() { display_label, }), } if preset.id == "full-access" - && profile_id == ":danger-no-sandbox" + && profile_id == BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS && display_label == "Full Access" )); } diff --git a/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs b/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs new file mode 100644 index 0000000000..b133c7ed04 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs @@ -0,0 +1,288 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn plugins_popup_uses_product_labels_for_remote_and_personal_tabs() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-directory", + "Raw Workspace Directory", + vec![plugins_test_remote_summary( + "plugins~Plugin_buildkite", + "buildkite", + Some("Buildkite"), + Some("Workspace CI."), + /*installed*/ false, + )], + ), + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Raw Shared Private", + vec![plugins_test_remote_summary( + "plugins~Plugin_docs", + "docs", + Some("Docs"), + Some("Shared docs."), + /*installed*/ false, + )], + ), + plugins_test_remote_marketplace( + "workspace-shared-with-me-unlisted", + "Raw Shared Link", + vec![plugins_test_remote_summary( + "plugins~Plugin_link", + "link", + Some("Link Share"), + Some("Shared by link."), + /*installed*/ false, + )], + ), + PluginMarketplaceEntry { + name: "codex-curated".to_string(), + path: Some(plugins_test_personal_marketplace_path()), + interface: Some(MarketplaceInterface { + display_name: Some("Personal".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-local-docs", + "local-docs", + Some("Local Docs"), + Some("Local editable docs."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + ]), + ); + + let rows = [ + ( + "[Workspace]", + "Workspace.", + "Buildkite", + "Raw Workspace Directory.", + ), + ( + "[Shared with me]", + "Shared with me.", + "Docs", + "Raw Shared Private.", + ), + ( + "[Shared with me (link)]", + "Shared with me (link).", + "Link Share", + "Raw Shared Link.", + ), + ("[Local]", "Local.", "Local Docs", "Personal."), + ] + .into_iter() + .map(|(selected_tab, product_label, plugin_name, raw_label)| { + let popup = select_plugins_tab_containing(&mut chat, /*width*/ 120, selected_tab); + assert!( + popup.contains(product_label) + && popup.contains(plugin_name) + && !popup.contains(raw_label), + "expected {selected_tab} to use its product label, got:\n{popup}" + ); + popup + .lines() + .find(|line| line.contains(plugin_name)) + .expect("expected plugin row") + .split_whitespace() + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n"); + + insta::assert_snapshot!( + rows, + @r###" + › [-] Buildkite Available Press Enter to install or view plugin details. + › [-] Docs Available Press Enter to install or view plugin details. + › [-] Link Share Available Press Enter to install or view plugin details. + › [-] Local Docs Available Press Enter to install or view plugin details. + "### + ); +} + +#[tokio::test] +async fn plugins_popup_preserves_workspace_tab_across_load_and_detail_navigation() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let workspace_marketplace = plugins_test_remote_marketplace( + "workspace-directory", + "Raw Workspace Directory", + vec![plugins_test_remote_summary( + "plugins~Plugin_buildkite", + "buildkite", + Some("Buildkite"), + Some("Buildkite pipelines."), + /*installed*/ false, + )], + ); + chat.add_plugins_output(); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let loading_popup = + select_plugins_tab_containing(&mut chat, /*width*/ 100, "Loading Workspace plugins."); + assert!( + loading_popup.contains("Loading Workspace plugins."), + "expected Workspace loading tab before remote sections resolve, got:\n{loading_popup}" + ); + + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + vec![workspace_marketplace.clone()], + Vec::new(), + ); + let workspace_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + workspace_popup.contains("Workspace.") + && workspace_popup.contains("Buildkite") + && !workspace_popup.contains("Loading Workspace plugins."), + "expected remote section refresh to keep the Workspace tab active, got:\n{workspace_popup}" + ); + + chat.open_plugin_detail_loading_popup("Buildkite"); + chat.open_plugins_list( + cwd.to_path_buf(), + plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + workspace_marketplace, + ]), + ); + let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + reopened_popup.contains("Workspace.") && reopened_popup.contains("Buildkite"), + "expected Back to plugins to preserve the Workspace tab, got:\n{reopened_popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_remote_local_dedupe_prefers_installed_remote_after_mapped_shares() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let remote_plugin_id = "plugins~Plugin_docs"; + let local_summary = PluginSummary { + remote_plugin_id: Some(remote_plugin_id.to_string()), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Local curated docs plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let cwd = chat.config.cwd.clone(); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![local_summary])]), + ); + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + vec![plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![plugins_test_remote_summary( + remote_plugin_id, + "docs", + Some("Docs"), + Some("Remote installed docs plugin."), + /*installed*/ true, + )], + )], + Vec::new(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + let PluginsCacheState::Ready(response) = &chat.plugins_cache else { + panic!("expected cached plugins after remote section refresh"); + }; + assert_eq!( + response + .marketplaces + .iter() + .map(|marketplace| marketplace.name.as_str()) + .collect::>(), + vec![ + OPENAI_CURATED_MARKETPLACE_NAME, + "workspace-shared-with-me-private" + ] + ); + let all_plugins_row = popup + .lines() + .find(|line| line.contains("Docs")) + .expect("expected all-plugins row"); + assert!( + popup.contains("Installed 1 of 1 available plugins."), + "expected header count to reflect deduped plugin rows, got:\n{popup}" + ); + assert!( + all_plugins_row.contains("Installed") + && !all_plugins_row.contains("Local curated docs plugin."), + "expected installed remote duplicate to win when local row is not a mapped share, got:\n{all_plugins_row}" + ); +} + +#[tokio::test] +async fn plugin_detail_not_installable_plugin_disables_install_action() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = plugins_test_summary( + "plugin-internal", + "internal", + Some("Internal"), + Some("Internal only."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::NotAvailable, + ); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![summary.clone()]), + ])), + ); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd.to_path_buf(), + Ok(PluginReadResponse { + plugin: plugins_test_detail(summary, Some("Internal only."), &[], &[], &[], &[]), + }), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + let install_row = popup + .lines() + .find(|line| line.contains("Install plugin")) + .expect("expected install row"); + assert!( + install_row.contains("This plugin is not installable from this marketplace."), + "expected disabled not-installable row, got:\n{install_row}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 100), + popup, + "expected navigation to skip the disabled install row" + ); +} diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 4c225b3318..f97d93a0c8 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -5,8 +5,12 @@ use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::MarketplaceLoadErrorInfo; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSource; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -196,7 +200,8 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ PluginInstallPolicy::Available, )]), ]); - let popup = render_loaded_plugins_popup(&mut chat, response); + render_loaded_plugins_popup(&mut chat, response); + let popup = render_bottom_popup(&chat, /*width*/ 320); assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup); assert!( popup.contains("Hidden Repo Plugin"), @@ -213,6 +218,27 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ ); } +#[tokio::test] +async fn plugins_popup_keeps_loaded_marketplace_state_with_load_errors() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let mut response = plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]); + response + .marketplace_load_errors + .push(MarketplaceLoadErrorInfo { + marketplace_path: plugins_test_absolute_path("marketplaces/broken"), + message: "failed to load marketplace manifest".to_string(), + }); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert!( + popup.contains("No marketplace plugins available") + && !popup.contains("Marketplace unavailable"), + "expected /plugins to keep the loaded marketplace state, got:\n{popup}" + ); +} + #[tokio::test] async fn plugins_popup_truncates_long_descriptions_in_list_rows() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -250,7 +276,7 @@ async fn plugins_popup_truncates_long_descriptions_in_list_rows() { .expect("expected verbose plugin row in popup"); insta::assert_snapshot!( verbose_row, - @" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…" + @" [-] Verbose Plugin Available · OpenAI Curated · This description…" ); assert!( !popup @@ -271,14 +297,14 @@ async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = select_plugins_tab_containing( + &mut chat, + /*width*/ 100, + "Add a marketplace from a Git repo or local root.", + ); assert!( popup.contains("Add a marketplace from a Git repo or local root."), - "expected Add Marketplace tab, got:\n{popup}" + "expected Add marketplace tab, got:\n{popup}" ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -348,11 +374,7 @@ async fn plugins_popup_upgrades_user_configured_git_marketplace_from_marketplace ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace."); assert!( popup.contains("Repo Marketplace.") && popup.contains("ctrl + u upgrade") @@ -459,11 +481,20 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); chat.add_plugins_output(); - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + let reopened_popup = (0..8) + .find_map(|_| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + if popup.contains("[Debug Marketplace]") { + Some(popup) + } else { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + None + } + }) + .unwrap_or_else(|| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + panic!("expected Debug Marketplace tab after reopening, got:\n{popup}"); + }); assert!( reopened_popup.contains("Installed 0 of 1 Debug Marketplace plugins.") && !reopened_popup.contains("installed successfully"), @@ -503,10 +534,8 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() { ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - let repo_tab = render_bottom_popup(&chat, /*width*/ 100); + let repo_tab = + select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace."); assert!( repo_tab.contains("Repo Marketplace.") && repo_tab.contains("ctrl + u upgrade") @@ -584,10 +613,17 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() { } #[tokio::test] -async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { +async fn plugin_detail_popup_snapshot_labels_personal_marketplace_as_local() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + let marketplace_name = "personal-marketplace"; + let personal_marketplace_path = AbsolutePathBuf::try_from( + dirs::home_dir() + .expect("home directory") + .join(".agents/plugins/marketplace.json"), + ) + .expect("absolute personal marketplace path"); let summary = plugins_test_summary( "plugin-figma", "figma", @@ -597,28 +633,29 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa /*enabled*/ true, PluginInstallPolicy::Available, ); - let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ - summary.clone(), - ])]); + let response = plugins_test_response(vec![PluginMarketplaceEntry { + name: marketplace_name.to_string(), + path: Some(personal_marketplace_path.clone()), + interface: None, + plugins: vec![summary.clone()], + }]); let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); - chat.on_plugin_detail_loaded( - cwd.to_path_buf(), - Ok(PluginReadResponse { - plugin: plugins_test_detail( - summary, - Some("Turn Figma files into implementation context."), - &["design-review", "extract-copy"], - &[ - (codex_app_server_protocol::HookEventName::PreToolUse, 1), - (codex_app_server_protocol::HookEventName::Stop, 2), - ], - &["Figma", "Slack"], - &["figma-mcp", "docs-mcp"], - ), - }), + let mut plugin = plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[ + (codex_app_server_protocol::HookEventName::PreToolUse, 1), + (codex_app_server_protocol::HookEventName::Stop, 2), + ], + &["Figma", "Slack"], + &["figma-mcp", "docs-mcp"], ); + plugin.marketplace_name = marketplace_name.to_string(); + plugin.marketplace_path = Some(personal_marketplace_path); + chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin })); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!( @@ -821,31 +858,23 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded( cwd.to_path_buf(), - Ok(plugins_test_response(vec![PluginMarketplaceEntry { - name: "workspace-shared-with-me-private".to_string(), - path: None, - interface: Some(MarketplaceInterface { - display_name: Some("Shared with me".to_string()), - }), - plugins: vec![summary.clone()], - }])), + Ok(plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![summary.clone()], + ), + ])), ); chat.add_plugins_output(); chat.on_plugin_detail_loaded( cwd.to_path_buf(), Ok(PluginReadResponse { - plugin: PluginDetail { - marketplace_name: "workspace-shared-with-me-private".to_string(), - marketplace_path: None, + plugin: plugins_test_remote_detail( + "workspace-shared-with-me-private", summary, - share_url: None, - description: Some("Installed shared Linear plugin.".to_string()), - skills: Vec::new(), - hooks: Vec::new(), - apps: Vec::new(), - app_templates: Vec::new(), - mcp_servers: Vec::new(), - }, + Some("Installed shared Linear plugin."), + ), }), ); @@ -876,7 +905,7 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { #[tokio::test] async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let summary = PluginSummary { @@ -894,31 +923,23 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded( cwd.to_path_buf(), - Ok(plugins_test_response(vec![PluginMarketplaceEntry { - name: "workspace-shared-with-me-private".to_string(), - path: None, - interface: Some(MarketplaceInterface { - display_name: Some("Shared with me".to_string()), - }), - plugins: vec![summary.clone()], - }])), + Ok(plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![summary.clone()], + ), + ])), ); chat.add_plugins_output(); chat.on_plugin_detail_loaded( cwd.to_path_buf(), Ok(PluginReadResponse { - plugin: PluginDetail { - marketplace_name: "workspace-shared-with-me-private".to_string(), - marketplace_path: None, + plugin: plugins_test_remote_detail( + "workspace-shared-with-me-private", summary, - share_url: None, - description: Some("Installed shared Linear plugin.".to_string()), - skills: Vec::new(), - hooks: Vec::new(), - apps: Vec::new(), - app_templates: Vec::new(), - mcp_servers: Vec::new(), - }, + Some("Installed shared Linear plugin."), + ), }), ); @@ -929,15 +950,58 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { "expected missing remote ID to disable uninstall, got:\n{popup}" ); - while rx.try_recv().is_ok() {} - assert!( - rx.try_recv().is_err(), - "expected no action after rendering disabled uninstall state" + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 120), + popup, + "expected navigation to skip the disabled uninstall row" ); } #[tokio::test] -async fn plugin_detail_admin_disabled_plugin_blocks_install() { +async fn plugin_detail_popup_shows_local_share_context_as_read_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: "plugins~Plugin_docs".to_string(), + remote_version: Some("7".to_string()), + discoverability: Some(PluginShareDiscoverability::Private), + share_url: Some("https://chatgpt.com/codex/plugins/share/docs".to_string()), + creator_account_user_id: None, + creator_name: Some("Test User".to_string()), + share_principals: None, + }), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Workspace docs."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); + chat.add_plugins_output(); + let mut detail = plugins_test_detail(summary, Some("Workspace docs."), &[], &[], &[], &[]); + detail.marketplace_path = Some(plugins_test_personal_marketplace_path()); + chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin: detail })); + + let popup = render_bottom_popup(&chat, /*width*/ 120); + assert_chatwidget_snapshot!( + "plugin_detail_popup_local_share_read_only", + strip_osc8_for_snapshot(&popup) + ); +} + +#[tokio::test] +async fn plugin_detail_popup_shows_admin_disabled_status_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); @@ -966,10 +1030,17 @@ async fn plugin_detail_admin_disabled_plugin_blocks_install() { }), ); - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = render_bottom_popup(&chat, /*width*/ 120); + let status_row = popup + .lines() + .find(|line| line.contains("Disabled by admin")) + .expect("expected admin-disabled status row"); + insta::assert_snapshot!( + status_row, + @" Admin Blocked · Disabled by admin · ChatGPT Marketplace" + ); assert!( - popup.contains("Admin Blocked · Disabled by admin") - && popup.contains("This plugin is disabled by your workspace admin.") + popup.contains("This plugin is disabled by your workspace admin.") && !popup.contains("Install this plugin now."), "expected admin-disabled detail to block install, got:\n{popup}" ); @@ -1003,18 +1074,18 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), ); - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( popup.contains("Disabled by admin") && popup.contains("Press Enter to view plugin details.") && !popup.contains("Space to disable"), - "expected admin-disabled installed plugin to omit toggle hint, got:\n{popup}" + "expected admin-disabled installed row to omit toggle hint, got:\n{popup}" ); while rx.try_recv().is_ok() {} - let before = render_bottom_popup(&chat, /*width*/ 100); + let before = render_bottom_popup(&chat, /*width*/ 120); chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - let after = render_bottom_popup(&chat, /*width*/ 100); + let after = render_bottom_popup(&chat, /*width*/ 120); assert!( rx.try_recv().is_err(), "space should not toggle admin-disabled installed plugins" @@ -1022,6 +1093,230 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { assert_eq!(after, before); } +#[tokio::test] +async fn plugins_popup_admin_disabled_available_plugin_has_view_only_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + availability: PluginAvailability::DisabledByAdmin, + ..plugins_test_summary( + "plugin-admin-blocked", + "admin-blocked", + Some("Admin Blocked"), + Some("Blocked by policy."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + let admin_blocked_row = popup + .lines() + .find(|line| line.contains("Admin Blocked")) + .expect("expected admin-disabled plugin row"); + assert!( + admin_blocked_row.contains("Press Enter to view plugin details.") + && !admin_blocked_row.contains("install or view"), + "expected admin-disabled available plugin to stay view-only, got:\n{admin_blocked_row}" + ); +} + +#[tokio::test] +async fn plugins_popup_remote_section_fallback_states_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let select_tab_containing = |chat: &mut ChatWidget, visible_text: &str| -> String { + for _ in 0..8 { + let popup = render_bottom_popup(chat, /*width*/ 100); + if popup.contains(visible_text) { + return popup; + } + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(chat, /*width*/ 100); + panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}"); + }; + let remote_section_state = |popup: &str| -> String { + let header = popup + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .nth(1) + .expect("expected remote section header"); + let item = popup + .lines() + .find_map(|line| line.trim_start().strip_prefix('›')) + .expect("expected selected remote section item") + .trim(); + format!("{header}\n{item}") + }; + + chat.add_plugins_output(); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let curated_loading_popup = + select_tab_containing(&mut chat, "Loading OpenAI Curated plugins..."); + let workspace_loading_popup = select_tab_containing(&mut chat, "Loading Workspace plugins."); + + chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), Vec::new(), Vec::new()); + let shared_empty_popup = select_tab_containing(&mut chat, "Shared with me."); + + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + Vec::new(), + vec![crate::app_event::PluginRemoteSectionError { + section_id: "workspace".to_string(), + label: "Workspace".to_string(), + message: "Sign in to ChatGPT to load workspace plugins.".to_string(), + }], + ); + let workspace_error_popup = select_tab_containing(&mut chat, "Workspace unavailable."); + + let (mut remote_chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + remote_chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + remote_chat.set_feature_enabled(Feature::RemotePlugin, /*enabled*/ true); + remote_chat.add_plugins_output(); + let remote_cwd = remote_chat.config.cwd.clone(); + remote_chat.on_plugins_loaded( + remote_cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let remote_curated_empty_popup = + select_tab_containing(&mut remote_chat, "No OpenAI Curated plugins available"); + + insta::assert_snapshot!( + [ + remote_section_state(&curated_loading_popup), + remote_section_state(&workspace_loading_popup), + remote_section_state(&shared_empty_popup), + remote_section_state(&workspace_error_popup), + remote_section_state(&remote_curated_empty_popup), + ] + .join("\n\n"), + @r###" + OpenAI Curated marketplace. + Loading OpenAI Curated plugins... This section updates when app-server returns it. + + Loading Workspace plugins. + Loading Workspace plugins... This section updates when app-server returns it. + + Shared with me. + No shared plugins available No plugins have been shared with you. + + Workspace unavailable. + Workspace unavailable Sign in to ChatGPT to load workspace plugins. + + OpenAI Curated marketplace. + No OpenAI Curated plugins available No OpenAI Curated plugins available. + "### + ); +} + +#[tokio::test] +async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let remote_plugin_id = "plugins~Plugin_docs"; + let remote_marketplace_name = "workspace-shared-with-me-private"; + let local_summary = PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: remote_plugin_id.to_string(), + remote_version: None, + discoverability: None, + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_principals: None, + }), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Local editable docs plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let popup = render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![local_summary]), + PluginMarketplaceEntry { + name: remote_marketplace_name.to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Shared with me".to_string()), + }), + plugins: vec![plugins_test_remote_summary( + remote_plugin_id, + "docs", + Some("Docs"), + Some("Shared docs plugin."), + /*installed*/ true, + )], + }, + ]), + ); + let all_plugins_row = popup + .lines() + .find(|line| line.contains("Docs")) + .expect("expected all-plugins row"); + assert!( + popup.contains("Installed 1 of 1 available plugins.") + && all_plugins_row.contains("Installed") + && !all_plugins_row.contains("Available"), + "expected installed remote duplicate to win over local mapped share, got:\n{popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + let installed_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + installed_popup.contains("Showing 1 installed plugins.") + && installed_popup.contains("Docs"), + "expected installed remote duplicate in the Installed tab, got:\n{installed_popup}" + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenPluginDetailLoading { + plugin_display_name, + }) => { + assert_eq!(plugin_display_name, "Docs"); + } + other => panic!("expected OpenPluginDetailLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchPluginDetail { params, .. }) => { + assert_eq!(params.marketplace_path, None); + assert_eq!( + params.remote_marketplace_name, + Some(remote_marketplace_name.to_string()) + ); + assert_eq!(params.plugin_name, remote_plugin_id); + } + other => panic!("expected FetchPluginDetail event, got {other:?}"), + } +} + #[tokio::test] async fn plugin_detail_error_popup_skips_disabled_row_numbering() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1531,7 +1826,14 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { chat.on_plugins_loaded(cwd.clone(), Ok(response.clone())); chat.add_plugins_output(); - for _ in 0..4 { + for _ in 0..10 { + let popup = render_bottom_popup(&chat, /*width*/ 100); + if popup.contains("Duplicate Marketplace (2/2).") + && popup.contains("Repo Plugin") + && !popup.contains("Home Plugin") + { + break; + } chat.handle_key_event(KeyEvent::from(KeyCode::Right)); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 73d033de03..d8b3f8a0cd 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -337,8 +337,12 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { assert!(chat.input_queue.queued_user_messages.is_empty()); } -async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; +async fn assert_cancelled_queued_menu_drains_next_input( + command: &str, + expected_popup_text: &str, + cancel_key: KeyEvent, +) { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -355,7 +359,14 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(cancel_key); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SettingsSelectionClosed)) + ); + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.maybe_send_next_queued_input(); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -372,39 +383,65 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ #[tokio::test] async fn queued_slash_menu_cancel_drains_next_input() { - assert_cancelled_queued_menu_drains_next_input("/model", "Select Model").await; - assert_cancelled_queued_menu_drains_next_input("/permissions", "Update Model Permissions") - .await; + assert_cancelled_queued_menu_drains_next_input( + "/model", + "Select Model", + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ) + .await; + assert_cancelled_queued_menu_drains_next_input( + "/permissions", + "Update Model Permissions", + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ) + .await; } #[tokio::test] -async fn queued_slash_menu_selection_drains_next_input() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; +async fn queued_settings_selection_applies_before_next_input() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.thread_id = Some(ThreadId::new()); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts.truncate(1); + let selected_effort = preset.supported_reasoning_efforts[0].effort.clone(); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); handle_turn_started(&mut chat, "turn-1"); - queue_composer_text_with_tab(&mut chat, "/permissions"); + queue_composer_text_with_tab(&mut chat, "/model"); queue_composer_text_with_tab(&mut chat, "hello after selection"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( - popup.contains("Update Model Permissions"), - "expected permissions menu to open; popup:\n{popup}" + popup.contains("Select Model and Effort"), + "expected model menu to open; popup:\n{popup}" ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::OpenReasoningPopup { model } => chat.open_reasoning_popup(model), + AppEvent::UpdateModel(model) => chat.set_model(&model), + AppEvent::UpdateReasoningEffort(effort) => chat.set_reasoning_effort(effort), + AppEvent::SettingsSelectionClosed => { + chat.app_event_tx.send(AppEvent::SettingsSelectionSettled); + } + AppEvent::SettingsSelectionSettled if chat.no_modal_or_popup_active() => { + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.maybe_send_next_queued_input(); + } + _ => {} + } + } match next_submit_op(&mut op_rx) { - Op::UserTurn { items, .. } => assert_eq!( - items, - vec![UserInput::Text { - text: "hello after selection".to_string(), - text_elements: Vec::new(), - }] + Op::UserTurn { model, effort, .. } => assert_eq!( + (model, effort), + ("gpt-5.4".to_string(), Some(selected_effort)) ), - other => panic!("expected queued message after permissions selection, got {other:?}"), + other => panic!("expected queued message with updated model, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty()); } @@ -1595,7 +1632,7 @@ async fn unavailable_slash_command_is_available_from_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); - submit_composer_text(&mut chat, "/model"); + submit_composer_text(&mut chat, "/review"); let cells = drain_insert_history(&mut rx); let rendered = cells @@ -1604,10 +1641,10 @@ async fn unavailable_slash_command_is_available_from_local_recall() { .collect::>() .join("\n"); assert!( - rendered.contains("'/model' is disabled while a task is in progress."), + rendered.contains("'/review' is disabled while a task is in progress."), "expected disabled-command message, got: {rendered:?}" ); - assert_eq!(recall_latest_after_clearing(&mut chat), "/model"); + assert_eq!(recall_latest_after_clearing(&mut chat), "/review"); } #[tokio::test] @@ -2293,8 +2330,9 @@ async fn slash_memory_update_reports_stubbed_feature() { } #[tokio::test] -async fn slash_resume_opens_picker() { +async fn slash_resume_opens_picker_while_mcp_startup_is_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); chat.dispatch_command(SlashCommand::Resume); @@ -2350,8 +2388,9 @@ async fn slash_delete_confirmation_requests_current_thread_delete() { } #[tokio::test] -async fn slash_resume_with_arg_requests_named_session() { +async fn slash_resume_with_arg_requests_named_session_while_mcp_startup_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); chat.bottom_pane.set_composer_text( "/resume my-saved-thread".to_string(), @@ -2587,8 +2626,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + chat.bottom_pane.set_task_running(/*running*/ true); - chat.handle_service_tier_command_dispatch(fast_tier_command()); + submit_composer_text(&mut chat, "/fast"); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_and_layout__unsupported_code_mode_warning.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_and_layout__unsupported_code_mode_warning.snap new file mode 100644 index 0000000000..96deb66773 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_and_layout__unsupported_code_mode_warning.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "lines_to_single_string(&cells[0])" +--- +⚠ Code Mode is enabled in configuration, but model `gpt-5.4` does not advertise + Code Mode support. This may degrade model performance. Disable + `features.code_mode` and `features.code_mode_only`, or select a model whose + metadata enables Code Mode. diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 583377bace..57de08cee5 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -14,6 +14,15 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) { chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); } +fn take_workspace_headline_request_id( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u64 { + match rx.try_recv() { + Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id, + event => panic!("expected workspace headline refresh, got {event:?}"), + } +} + /// Receiving a token usage update without usage clears the context indicator. #[tokio::test] async fn token_count_none_resets_context_indicator() { @@ -1023,6 +1032,39 @@ async fn rate_limit_switch_prompt_shows_once_per_session() { )); } +#[tokio::test] +async fn account_update_clears_derived_usage_limit_state_and_prompt() { + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + set_chatgpt_auth(&mut chat); + let mut limits = snapshot(/*percent*/ 95.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + chat.maybe_show_pending_rate_limit_prompt(); + + assert!(chat.rate_limit_warnings.primary_index > 0); + assert!(chat.codex_rate_limit_reached_type.is_some()); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + assert!(!chat.bottom_pane.no_modal_or_popup_active()); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + + assert_eq!(chat.rate_limit_warnings.primary_index, 0); + assert_eq!(chat.rate_limit_warnings.secondary_index, 0); + assert_eq!(chat.codex_rate_limit_reached_type, None); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn rate_limit_switch_prompt_respects_hidden_notice() { let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; @@ -2037,6 +2079,22 @@ async fn warning_event_adds_warning_history_cell() { ); } +#[tokio::test] +async fn unsupported_code_mode_warning_renders_as_warning_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + handle_warning( + &mut chat, + "Code Mode is enabled in configuration, but model `gpt-5.4` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode.", + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + insta::assert_snapshot!( + "unsupported_code_mode_warning", + lines_to_single_string(&cells[0]) + ); +} + #[tokio::test] async fn repeated_model_metadata_warning_is_hidden_for_same_slug() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -2141,6 +2199,192 @@ async fn status_line_legacy_context_usage_renders_context_used_percent() { ); } +#[tokio::test] +async fn status_line_workspace_headline_renders_cached_value() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + + chat.refresh_status_line(); + + assert_eq!( + status_line_text(&chat), + Some("Workspace maintenance starts at 5pm".to_string()) + ); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be a valid status line item" + ); +} + +#[tokio::test] +async fn status_line_workspace_headline_omits_when_unavailable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec![ + "workspace-headline".to_string(), + "run-state".to_string(), + ]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Ready".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be omitted without warning when no headline is cached" + ); +} + +#[tokio::test] +async fn workspace_headline_update_applies_feature_disabled_result() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old headline".to_string()); + let request_id = 3; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + + assert_eq!(status_line_text(&chat), None); + assert!(chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn workspace_headline_update_applies_available_headline() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + let request_id = 4; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Fresh workspace headline".to_string(), + )) + ), + )); + + assert_eq!( + status_line_text(&chat), + Some("Fresh workspace headline".to_string()) + ); + assert!(!chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn account_update_clears_workspace_headline_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); + chat.status_line_workspace_headline_pending_request_id = Some(5); + chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); + chat.status_line_workspace_messages_disabled = true; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_headline_last_requested_at, + chat.status_line_workspace_messages_disabled, + ), + (None, None, None, false) + ); +} + +#[tokio::test] +async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + ); + + let request_id = take_workspace_headline_request_id(&mut rx); + assert_eq!( + chat.status_line_workspace_headline_pending_request_id, + Some(request_id) + ); +} + +#[tokio::test] +async fn account_update_discards_stale_workspace_headline_results() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("first@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let stale_request_id = take_workspace_headline_request_id(&mut rx); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("second@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let current_request_id = take_workspace_headline_request_id(&mut rx); + + assert_ne!(stale_request_id, current_request_id); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "First account headline".to_string(), + )) + ), + )); + assert_eq!( + ( + chat.status_line_workspace_headline.clone(), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (None, Some(current_request_id), false) + ); + + assert!(chat.set_status_line_workspace_headline( + current_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Second account headline".to_string(), + )) + ), + )); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (Some("Second account headline".to_string()), None, false,) + ); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 953de6b89c..21e02db252 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -46,8 +46,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( other => panic!("expected rate-limit refresh request, got {other:?}"), }; - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, vec![snapshot(/*percent*/ 92.0)]); drain_insert_history(&mut rx); chat.dispatch_command(SlashCommand::Status); @@ -170,10 +169,31 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { "expected /status to avoid transient refresh text in terminal history, got: {second_rendered}" ); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, Vec::new()); pretty_assertions::assert_eq!(chat.refreshing_status_outputs.len(), 1); - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(second_request_id); + chat.finish_status_rate_limit_refresh(second_request_id, vec![snapshot(/*percent*/ 92.0)]); assert!(chat.refreshing_status_outputs.is_empty()); } + +#[tokio::test] +async fn account_update_rejects_stale_status_rate_limit_snapshots() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.dispatch_command(SlashCommand::Status); + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + let request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + }) => request_id, + other => panic!("expected status refresh request, got {other:?}"), + }; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_status_rate_limit_refresh(request_id, vec![snapshot(/*percent*/ 92.0)]); + + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); +} diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8654b37d24..63005cb957 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -180,6 +180,18 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() { ); } +#[tokio::test] +async fn status_line_setup_popup_workspace_headline_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_workspace_headline", + status_line_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn status_surface_preview_lines_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 4e49e3561c..c05b4a62ff 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -13,6 +13,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -27,12 +28,13 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { } #[tokio::test] -async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot() { +async fn usage_command_disables_reset_after_cached_zero_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); @@ -42,11 +44,109 @@ async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot "usage_command_menu_without_resets", render_bottom_popup(&chat, /*width*/ 80) ); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn usage_menu_refresh_enables_newly_available_reset() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } +#[tokio::test] +async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Err("backend unavailable".to_string()), + ); + + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("No usage limit resets available.")); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unchanged() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let startup_request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + startup_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + vec![snapshot(/*percent*/ 92.0)], + Ok(RateLimitResetCreditsSummary { available_count: 2 }), + ); + + assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn usage_command_can_check_reset_availability_before_startup_refresh_finishes_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -65,7 +165,7 @@ async fn usage_command_can_check_reset_availability_before_startup_refresh_finis } #[tokio::test] -async fn usage_command_disables_reset_for_workspace_accounts() { +async fn usage_command_can_check_reset_availability_for_workspace_accounts() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -74,7 +174,7 @@ async fn usage_command_disables_reset_for_workspace_accounts() { chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } #[tokio::test] @@ -84,6 +184,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); chat.dispatch_command(SlashCommand::Usage); @@ -104,6 +205,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); record_popup(&chat, &mut states); @@ -112,6 +214,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let empty_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); record_popup(&chat, &mut states); @@ -120,6 +223,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let load_error_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( load_error_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); record_popup(&chat, &mut states); @@ -165,6 +269,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); record_popup(&chat, &mut states); @@ -172,12 +277,66 @@ async fn rate_limit_reset_popup_states_snapshot() { assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n")); } +#[tokio::test] +async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let mut states = Vec::new(); + + chat.plan_type = Some(PlanType::Free); + let free_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + free_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Free:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + dismiss_popup(&mut chat); + chat.plan_type = Some(PlanType::Go); + let go_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + go_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); + + dismiss_popup(&mut chat); + let mut monthly_business_snapshot = snapshot(/*percent*/ 50.0); + monthly_business_snapshot.plan_type = Some(PlanType::Business); + monthly_business_snapshot + .primary + .as_mut() + .expect("monthly business snapshot primary window") + .window_duration_mins = Some(30 * 24 * 60); + let business_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + business_request_id, + vec![monthly_business_snapshot], + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Business with monthly window:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + assert_chatwidget_snapshot!( + "usage_limit_reset_confirmation_monthly", + states.join("\n---\n") + ); +} + #[tokio::test] async fn rate_limit_reset_confirmation_selects_cancel_by_default() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -193,6 +352,7 @@ async fn rate_limit_reset_confirmation_can_use_reset() { let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -226,12 +386,13 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { } #[tokio::test] -async fn no_credit_outcome_allows_reset_availability_recheck() { +async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -244,10 +405,16 @@ async fn no_credit_outcome_allows_reset_availability_recheck() { dismiss_popup(&mut chat); chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 2 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); } #[tokio::test] @@ -270,6 +437,7 @@ async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() { assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); dismiss_popup(&mut chat); @@ -300,11 +468,12 @@ async fn already_redeemed_is_an_idempotent_success() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 0 rate-limit resets left.") + .contains("Usage reset. You have 0 usage limit resets left.") ); } @@ -315,6 +484,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -327,6 +497,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_post_consume_reset_credits_refresh( consume_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); dismiss_popup(&mut chat); @@ -334,7 +505,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let rendered = render_bottom_popup(&chat, /*width*/ 80); assert!(rendered.contains("Check reset availability.")); - assert!(!rendered.contains("You have 2 rate-limit resets available.")); + assert!(!rendered.contains("You have 2 usage limit resets available.")); } #[tokio::test] @@ -350,6 +521,7 @@ async fn account_change_invalidates_pending_reset_requests() { assert!(!chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -363,6 +535,7 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -385,6 +558,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert_eq!( @@ -395,7 +569,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("You have 2 rate-limit resets available.") + .contains("You have 2 usage limit resets available.") ); } @@ -413,6 +587,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); assert_eq!( @@ -423,7 +598,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 1 rate-limit reset left.") + .contains("Usage reset. You have 1 usage limit reset left.") ); } @@ -455,6 +630,7 @@ async fn startup_check_shows_available_reset_hint_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let rendered = lines_to_single_string( @@ -477,6 +653,7 @@ async fn startup_reset_hint_waits_for_active_output_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -507,6 +684,7 @@ async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() { assert!(!chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); @@ -519,6 +697,7 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -536,13 +715,14 @@ async fn startup_check_omits_reset_hint_when_none_are_available() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } #[tokio::test] -async fn startup_check_omits_reset_hint_for_workspace_accounts() { +async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -550,10 +730,11 @@ async fn startup_check_omits_reset_hint_for_workspace_accounts() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); - assert!(chat.pending_rate_limit_reset_hint().is_none()); - assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.pending_rate_limit_reset_hint().is_some()); + assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); } fn consume_response( diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 4cee18af11..53925e86d9 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -3,6 +3,7 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::RateLimitResetCreditsSummary; use uuid::Uuid; +use super::rate_limits::get_limits_duration; use super::*; const USAGE_MENU_VIEW_ID: &str = "usage-menu"; @@ -11,8 +12,21 @@ const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset"; impl ChatWidget { pub(super) fn open_usage_menu(&mut self) { self.clear_pending_rate_limit_reset_hint(); - let reset_eligible = - self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_account); + let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0); + self.bottom_pane + .show_selection_view(self.usage_menu_params()); + if should_refresh_reset_availability { + let request_id = self.take_next_rate_limit_reset_request_id(); + self.pending_usage_menu_rate_limit_request_id = Some(request_id); + self.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id }, + }); + } + self.request_redraw(); + } + + fn usage_menu_params(&self) -> SelectionViewParams { + let reset_eligible = self.has_chatgpt_account; let (reset_action_enabled, reset_description) = match (reset_eligible, self.available_rate_limit_reset_credits) { (true, Some(available_count)) if available_count > 0 => ( @@ -22,10 +36,12 @@ impl ChatWidget { reset_label(available_count) ), ), - (true, _) => (true, "Check reset availability.".to_string()), - (false, _) => (false, "No rate-limit resets available.".to_string()), + (true, None) => (true, "Check reset availability.".to_string()), + (true, Some(_)) | (false, _) => { + (false, "No usage limit resets available.".to_string()) + } }; - self.bottom_pane.show_selection_view(SelectionViewParams { + SelectionViewParams { view_id: Some(USAGE_MENU_VIEW_ID), title: Some("Usage".to_string()), subtitle: Some("View account usage or redeem an earned reset.".to_string()), @@ -41,7 +57,7 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Redeem rate limit reset".to_string(), + name: "Redeem usage limit reset".to_string(), description: Some(reset_description), is_disabled: !reset_action_enabled, actions: vec![Box::new(|tx| { @@ -52,8 +68,32 @@ impl ChatWidget { }, ], ..Default::default() - }); - self.request_redraw(); + } + } + + pub(crate) fn finish_usage_menu_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + result: Result, + ) { + if self.pending_usage_menu_rate_limit_request_id != Some(request_id) { + return; + } + self.pending_usage_menu_rate_limit_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + if let Ok(response) = result { + self.available_rate_limit_reset_credits = Some(response.available_count); + } + let params = self.usage_menu_params(); + if self + .bottom_pane + .replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params) + { + self.request_redraw(); + } } pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 { @@ -62,7 +102,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Checking your available resets...".to_string()), items: vec![SelectionItem { name: "Loading...".to_string(), @@ -78,26 +118,30 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let params = match result { Ok(response) => { self.available_rate_limit_reset_credits = Some(response.available_count); if response.available_count > 0 { - Self::rate_limit_reset_confirmation_params(response.available_count) + self.rate_limit_reset_confirmation_params(response.available_count) } else { Self::rate_limit_reset_message_params( - "You don't have any rate-limit resets available.", + "You don't have any usage limit resets available.", ) } } Err(_) => Self::rate_limit_reset_message_params( - "Couldn't load rate-limit resets. Please try again.", + "Couldn't load usage limit resets. Please try again.", ), }; let replaced = self @@ -109,11 +153,32 @@ impl ChatWidget { replaced } - fn rate_limit_reset_confirmation_params(available_count: i64) -> SelectionViewParams { + fn rate_limit_reset_confirmation_params(&self, available_count: i64) -> SelectionViewParams { let idempotency_key = Uuid::new_v4().to_string(); + let has_monthly_window = self + .rate_limit_snapshots_by_limit_id + .iter() + .find(|(limit_id, _)| limit_id.eq_ignore_ascii_case("codex")) + .into_iter() + .flat_map(|(_, snapshot)| [snapshot.primary.as_ref(), snapshot.secondary.as_ref()]) + .flatten() + .any(|window| { + window + .window_minutes + .and_then(get_limits_duration) + .as_deref() + == Some("monthly") + }); + let reset_description = if has_monthly_window + || matches!(self.plan_type, Some(PlanType::Free | PlanType::Go)) + { + "Reset your current monthly usage limit." + } else { + "Reset your current 5-hour and weekly usage limits." + }; SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(format!( "You have {available_count} {} available.", reset_label(available_count) @@ -122,7 +187,7 @@ impl ChatWidget { items: vec![ SelectionItem { name: "Use a reset".to_string(), - description: Some("Reset your current Codex usage windows.".to_string()), + description: Some(reset_description.to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::ConsumeRateLimitResetCredit { idempotency_key: idempotency_key.clone(), @@ -145,7 +210,7 @@ impl ChatWidget { fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), items: vec![SelectionItem { name: "Close".to_string(), @@ -162,7 +227,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Resetting your usage...".to_string()), items: vec![SelectionItem { name: "Using a reset...".to_string(), @@ -206,7 +271,7 @@ impl ChatWidget { } ConsumeAccountRateLimitResetCreditOutcome::NoCredit => { self.available_rate_limit_reset_credits = Some(0); - "No rate-limit resets are available." + "No usage limit resets are available." } ConsumeAccountRateLimitResetCreditOutcome::Reset | ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(), @@ -218,7 +283,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = None; self.replace_rate_limit_reset_popup(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Couldn't reset usage. Please try again.".to_string()), items: vec![ SelectionItem { @@ -247,12 +312,16 @@ impl ChatWidget { pub(crate) fn finish_post_consume_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let message = match result { Ok(response) => { @@ -272,7 +341,7 @@ impl ChatWidget { fn rate_limit_reset_success_loading_params() -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Usage reset. Checking your remaining resets...".to_string()), items: vec![SelectionItem { name: "Refreshing...".to_string(), @@ -303,18 +372,19 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_hint_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_hint_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_hint_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } if !self.has_codex_backend_auth { return false; } - if self.plan_type.is_some_and(PlanType::is_workspace_account) { - return true; - } if let Ok(response) = result { self.available_rate_limit_reset_credits = Some(response.available_count); self.set_rate_limit_reset_available_hint(response.available_count); @@ -324,8 +394,11 @@ impl ChatWidget { pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) { self.pending_rate_limit_reset_request_id = None; + self.pending_usage_menu_rate_limit_request_id = None; self.available_rate_limit_reset_credits = None; + self.rate_limit_snapshots_by_limit_id.clear(); self.clear_pending_rate_limit_reset_hint(); + self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID); self.bottom_pane .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); } @@ -375,8 +448,8 @@ impl ChatWidget { fn reset_label(count: i64) -> &'static str { if count == 1 { - "rate-limit reset" + "usage limit reset" } else { - "rate-limit resets" + "usage limit resets" } } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 995a0358f6..4800103915 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -223,7 +223,7 @@ where )) } - fn with_screen_size_and_cursor_position( + pub(crate) fn with_screen_size_and_cursor_position( backend: B, screen_size: Size, cursor_pos: Position, diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 599a44f092..b71af3d251 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -414,7 +414,7 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); if let Some(cwd) = cwd.as_ref() { - lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into()); + lines.push(vec![" • Cwd: ".into(), cwd.to_string().into()].into()); } let env_display = format_env_display(env.as_ref(), env_vars); diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap new file mode 100644 index 0000000000..3652ad4428 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +■ Amazon Bedrock rejected the request because its AWS signature has expired. Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or unset it, then restart Codex, url: https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 4d23392128..99f7a3a93b 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -15,11 +15,13 @@ use codex_otel::RuntimeMetricTotals; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::error::UnexpectedResponseError; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use reqwest::StatusCode; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -752,6 +754,31 @@ fn error_event_oversized_input_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn error_event_bedrock_expired_signature_snapshot() { + let error = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z \ +(20260614T063025Z - 5 min.)" + .to_string(), + user_message: Some( + "Amazon Bedrock rejected the request because its AWS signature has expired. \ +Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or \ +unset it, then restart Codex" + .to_string(), + ), + url: Some("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses".to_string()), + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }; + let cell = new_error_event(error.to_string()); + let rendered = render_lines(&cell.display_lines(/*width*/ 100)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f189b23149..290430604d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -10,6 +10,7 @@ use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::ConfigTomlLoadResult; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use crate::legacy_core::format_exec_policy_error_with_source; @@ -74,12 +75,10 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; pub use token_usage::TokenUsage; -use tracing::Level; use tracing::error; use tracing::warn; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; -use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; use url::Url; use uuid::Uuid; @@ -200,6 +199,7 @@ mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; +mod workspace_messages; mod wrapping; @@ -958,6 +958,14 @@ pub async fn run_main( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -966,6 +974,7 @@ pub async fn run_main( .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; @@ -1155,6 +1164,7 @@ pub async fn run_main( } if !app_server_target.uses_remote_workspace() { + let auth_route_config = config.auth_route_config(); #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), @@ -1163,6 +1173,7 @@ pub async fn run_main( forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + auth_route_config, }) .await { @@ -1231,7 +1242,7 @@ pub async fn run_main( let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() - .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + .map(|layer| layer.with_filter(log_db::default_filter())); let _ = tracing_subscriber::registry() .with(tui_file_layer) @@ -1430,6 +1441,7 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, initial_config.auth_keyring_backend_kind(), initial_config.chatgpt_base_url.clone(), + initial_config.auth_route_config(), ) .await; } @@ -2041,6 +2053,7 @@ mod tests { std::fs::create_dir_all(parent)?; let session_meta = codex_protocol::protocol::SessionMeta { + session_id: thread_id.into(), id: thread_id, timestamp: meta_rfc3339.to_string(), cwd: cwd.to_path_buf(), diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0170ae49d4..8ba409f7c9 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1040,6 +1040,7 @@ mod tests { AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), "https://chatgpt.com/backend-api/".to_string(), + /*auth_route_config*/ None, ) .await, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 852aaad5ce..56bcfae6ec 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -13,6 +13,7 @@ use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use codex_app_server_protocol::Thread as AppServerThread; @@ -329,12 +330,21 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e68b3e1cfa..646380a2fa 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -103,7 +103,7 @@ impl SlashCommand { SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::Usage => "view account usage or use a rate-limit reset", + SlashCommand::Usage => "view account usage or use a usage limit reset", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", @@ -190,13 +190,9 @@ impl SlashCommand { SlashCommand::New | SlashCommand::Archive | SlashCommand::Delete - | SlashCommand::Resume | SlashCommand::Fork | SlashCommand::Init | SlashCommand::Compact - | SlashCommand::Model - | SlashCommand::Personality - | SlashCommand::Permissions | SlashCommand::Keymap | SlashCommand::Vim | SlashCommand::ElevateSandbox @@ -211,6 +207,10 @@ impl SlashCommand { | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, SlashCommand::Diff + | SlashCommand::Resume + | SlashCommand::Model + | SlashCommand::Personality + | SlashCommand::Permissions | SlashCommand::Copy | SlashCommand::Raw | SlashCommand::Rename diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap new file mode 100644 index 0000000000..4ac5239b94 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ Account: Enterprise │ +│ │ +│ Limits: data not available yet │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 4ab7a2cfa2..50ef2c5000 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -17,6 +17,9 @@ use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::TimeZone; @@ -27,6 +30,7 @@ use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::test_support::construct_model_info_offline_for_tests; @@ -318,6 +322,67 @@ async fn status_snapshot_includes_reasoning_details() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_shows_chatgpt_plan_without_email() { + let temp_home = TempDir::new().expect("temp home"); + write_models_cache(temp_home.path()).expect("write models cache"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + write_chatgpt_auth( + temp_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("enterprise"), + AuthCredentialsStoreMode::File, + ) + .expect("write email-less ChatGPT auth"); + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("start embedded app server"); + let bootstrap = app_server + .bootstrap(&config) + .await + .expect("bootstrap app server session"); + app_server.shutdown().await.expect("shut down app server"); + let account_display = bootstrap + .status_account_display + .expect("bootstrap should return ChatGPT account display"); + assert_eq!( + account_display, + StatusAccountDisplay::ChatGpt { + email: None, + plan: Some("Enterprise".to_string()), + } + ); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + + let composite = new_status_output( + &config, + Some(&account_display), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let sanitized = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 80))).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_permissions_non_default_workspace_write_uses_workspace_label() { let temp_home = TempDir::new().expect("temp home"); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index a0f7ade044..7bf72f7cdf 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -60,6 +60,8 @@ mod frame_requester; mod job_control; mod keyboard_modes; mod terminal_stderr; +#[cfg(test)] +pub(crate) mod test_support; /// Target frame interval for UI redraw scheduling. pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL; diff --git a/codex-rs/tui/src/tui/test_support.rs b/codex-rs/tui/src/tui/test_support.rs new file mode 100644 index 0000000000..5c1f30eec0 --- /dev/null +++ b/codex-rs/tui/src/tui/test_support.rs @@ -0,0 +1,28 @@ +use std::io; +use std::io::stdout; + +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Position; +use ratatui::layout::Size; + +use super::Tui; +use super::terminal_stderr::TerminalStderrGuard; +use crate::custom_terminal::Terminal; + +pub(crate) fn make_test_tui() -> io::Result { + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::with_screen_size_and_cursor_position( + backend, + Size { + width: 80, + height: 24, + }, + Position { x: 0, y: 0 }, + ); + let stderr_guard = TerminalStderrGuard::install()?; + Ok(Tui::new( + terminal, + /*enhanced_keys_supported*/ false, + stderr_guard, + )) +} diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 0000000000..4f5695f80c --- /dev/null +++ b/codex-rs/tui/src/workspace_messages.rs @@ -0,0 +1,29 @@ +use codex_app_server_protocol::GetWorkspaceMessagesResponse; +use codex_app_server_protocol::WorkspaceMessageType; +use std::time::Duration; + +pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum WorkspaceHeadlineFetchResult { + Available(Option), + FeatureDisabled, +} + +pub(crate) fn workspace_headline_from_response( + response: GetWorkspaceMessagesResponse, +) -> WorkspaceHeadlineFetchResult { + if !response.feature_enabled { + return WorkspaceHeadlineFetchResult::FeatureDisabled; + } + + WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| { + (message.message_type == WorkspaceMessageType::Headline) + .then(|| message.message_body.trim().to_string()) + .filter(|headline| !headline.is_empty()) + })) +} + +#[cfg(test)] +#[path = "workspace_messages_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/workspace_messages_tests.rs b/codex-rs/tui/src/workspace_messages_tests.rs new file mode 100644 index 0000000000..962d2ceba1 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use codex_app_server_protocol::WorkspaceMessage; +use pretty_assertions::assert_eq; + +#[test] +fn workspace_headline_from_response_uses_first_non_empty_headline() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![ + WorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: WorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "empty-headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " ".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " Workspace headline ".to_string(), + created_at: None, + archived_at: None, + }, + ], + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string())) + ); +} + +#[test] +fn workspace_headline_from_response_reports_feature_disabled() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: false, + messages: Vec::new(), + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::FeatureDisabled + ); +} diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 3e33f316ca..e4fdbe7c27 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use serde::Serialize; use serde::Serializer; use std::fmt; +use std::path::Path; use thiserror::Error; use ts_rs::TS; @@ -18,10 +19,10 @@ use ts_rs::TS; /// /// When converting from [`PathUri`], "native" refers to the supplied /// [`PathConvention`], which may be foreign to the operating system running -/// this process. The inner string is private so path-producing code must convert -/// from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing -/// the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8 -/// lossily because this API value is serialized as a JSON string. +/// this process. The inner string is private so path-producing code must use a +/// path conversion method instead of bypassing the intended conversion +/// boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API +/// value is serialized as a JSON string. /// /// Deserialization accepts any UTF-8 string without interpreting or validating /// it. That unrestricted construction path is intentionally available only to @@ -35,9 +36,14 @@ use ts_rs::TS; pub struct LegacyAppPathString(String); impl LegacyAppPathString { + /// Preserves path text without interpreting it using the current host. + pub fn from_path(path: &Path) -> Self { + Self(path.to_string_lossy().into_owned()) + } + /// Renders an absolute path using the current host's path convention. pub fn from_abs_path(path: &AbsolutePathBuf) -> Self { - Self(path.to_string_lossy().into_owned()) + Self::from_path(path.as_path()) } /// Renders a path URI using the requested native path convention. diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index 3439ca11ea..c71a65abf3 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -496,6 +496,23 @@ fn foreign_absolute_syntax_deserializes_without_host_interpretation() { } } +#[test] +fn from_path_preserves_foreign_absolute_path_for_uri_conversion() { + #[cfg(not(windows))] + let (foreign_path, expected_uri) = (r"C:\Users\openai\share", "file:///C:/Users/openai/share"); + #[cfg(windows)] + let (foreign_path, expected_uri) = ("/home/openai/share", "file:///home/openai/share"); + + let path: PathUri = LegacyAppPathString::from_path(std::path::Path::new(foreign_path)) + .try_into() + .expect("foreign absolute path should convert"); + + assert_eq!( + path, + PathUri::parse(expected_uri).expect("valid expected URI") + ); +} + #[test] fn renders_an_absolute_path_using_the_host_convention() { #[cfg(unix)] diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 5ef1df2fa2..9794e7c709 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -121,7 +121,7 @@ impl PathUri { /// Relative paths are reported as invalid input. Absolute paths without a /// valid URI representation use the fallback documented on /// [`Self::from_abs_path`]. - pub fn from_path(path: impl AsRef) -> io::Result { + pub fn from_host_native_path(path: impl AsRef) -> io::Result { let path = AbsolutePathBuf::from_absolute_path_checked(path) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; Ok(Self::from_abs_path(&path)) @@ -247,6 +247,33 @@ impl PathUri { std::iter::successors(Some(self.clone()), Self::parent) } + /// Returns true when this URI is lexically equal to or below `base`. + /// + /// Containment is computed using URI authority and path-segment boundaries, + /// without consulting the host filesystem. Percent-encoded path separators + /// fail closed because native path conversion may interpret them as segment + /// boundaries. Opaque fallback URIs created by [`Self::from_abs_path`] only + /// contain themselves. + pub fn starts_with(&self, base: &Self) -> bool { + if self == base { + return true; + } + if decode_bad_path_uri(&self.0).is_some() || decode_bad_path_uri(&base.0).is_some() { + return false; + } + if self.0.host_str() != base.0.host_str() { + return false; + } + + let Some(path_segments) = containment_path_segments(&self.0) else { + return false; + }; + let Some(base_segments) = containment_path_segments(&base.0) else { + return false; + }; + path_segments.starts_with(&base_segments) + } + /// Lexically resolves native absolute or relative path text against this URI. /// /// Path text is interpreted using the POSIX or Windows convention inferred @@ -542,6 +569,19 @@ fn is_windows_drive_uri_segment(segment: &str) -> bool { matches!(segment.as_bytes(), [drive, b':'] if drive.is_ascii_alphabetic()) } +fn containment_path_segments(url: &Url) -> Option> { + let segments = url + .path_segments()? + .filter(|segment| !segment.is_empty()) + .collect::>(); + (!segments.iter().any(|segment| { + urlencoding::decode_binary(segment.as_bytes()) + .iter() + .any(|byte| matches!(*byte, b'/' | b'\\')) + })) + .then_some(segments) +} + fn infer_opaque_path_convention(path_bytes: &[u8]) -> Option { if path_bytes.starts_with(b"/") { return Some(PathConvention::Posix); diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 075d343d01..c52c3f9e6a 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -283,9 +283,16 @@ fn structurally_valid_bad_path_uri_with_invalid_native_payload_fails_conversion( fn bad_path_uris_are_opaque_to_lexical_operations() { let uri = PathUri::parse("file:///%00/bad/path/YQ") .expect("canonical base64 fallback URI should parse"); + let other = PathUri::parse("file:///%00/bad/path/Yg") + .expect("canonical base64 fallback URI should parse"); + let root = PathUri::parse("file:///").expect("valid root URI"); assert_eq!(uri.basename(), None); assert_eq!(uri.parent(), None); + assert!(uri.starts_with(&uri)); + assert!(!uri.starts_with(&root)); + assert!(!uri.starts_with(&other)); + assert!(!other.starts_with(&uri)); assert_eq!(uri.join(""), Ok(uri.clone())); assert_eq!( uri.join("child"), @@ -423,7 +430,8 @@ fn path_uri_deserializes_legacy_absolute_paths() { #[test] fn path_uri_rejects_relative_native_paths() { - let error = PathUri::from_path("src/lib.rs").expect_err("relative path should be rejected"); + let error = + PathUri::from_host_native_path("src/lib.rs").expect_err("relative path should be rejected"); assert_eq!(error.kind(), io::ErrorKind::InvalidInput); } @@ -697,6 +705,58 @@ fn join_uses_the_base_uri_path_convention() { } } +#[test] +fn starts_with_uses_uri_segment_boundaries() { + for (path, base, expected) in [ + ("file:///workspace/plugin", "file:///", true), + ("file:///workspace/plugin", "file:///workspace/plugin", true), + ( + "file:///workspace/plugin/assets/icon.svg", + "file:///workspace/plugin", + true, + ), + ( + "file:///workspace/plugin-other/icon.svg", + "file:///workspace/plugin", + false, + ), + ( + "file:///C:/plugins/foo/assets/icon.svg", + "file:///C:/plugins/foo", + true, + ), + ( + "file:///C:/plugins/foo2/assets/icon.svg", + "file:///C:/plugins/foo", + false, + ), + ( + "file://server/share/plugins/foo/icon.svg", + "file://server/share/plugins/foo", + true, + ), + ( + "file://other/share/plugins/foo/icon.svg", + "file://server/share/plugins/foo", + false, + ), + ( + "file:///workspace/plugin/%2F..%2Foutside", + "file:///workspace/plugin", + false, + ), + ( + "file:///workspace/plugin/%5C..%5Coutside", + "file:///workspace/plugin", + false, + ), + ] { + let path = PathUri::parse(path).expect("valid path URI"); + let base = PathUri::parse(base).expect("valid base URI"); + assert_eq!(path.starts_with(&base), expected); + } +} + #[test] fn to_url_returns_the_validated_url() { let uri = PathUri::parse("file://localhost/workspace/a%20file.rs").expect("valid file URI"); diff --git a/justfile b/justfile index 560fb1c22e..03f87391ac 100644 --- a/justfile +++ b/justfile @@ -38,11 +38,11 @@ app-server-test-client *args: # Format the justfile, Rust, Bazel/Starlark, Python SDK code, and Python scripts. fmt: - {{ python }} ../scripts/format.py + @{{ python }} ../scripts/format.py # Check formatting without modifying files. fmt-check: - {{ python }} ../scripts/format.py --check + @{{ python }} ../scripts/format.py --check fix *args: cargo clippy --fix --tests --allow-dirty {args} diff --git a/package.json b/package.json index 0c99b5e8b0..ddda581bf2 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "resolutions": { "@modelcontextprotocol/sdk": "1.26.0", "braces": "^3.0.3", + "esbuild": "0.28.1", + "fast-uri": "3.1.1", "flatted": "3.4.2", "glob@10.4.5": "10.5.0", "handlebars": "4.7.9", + "hono": "4.12.25", "micromatch": "^4.0.8", "minimatch@3.1.2": "3.1.4", "minimatch@9.0.5": "9.0.7", diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index a431c3f3a6..ed14137170 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -6,7 +6,7 @@ exports_files([ "bzip2_windows_stack_args.patch", "llvm_rusty_v8_custom_libcxx.patch", "llvm_windows_arm64_powl.patch", - "llvm_windows_symlink_extract.patch", + "llvm_windows_mingw_compat.patch", "rules_rust_windows_bootstrap_process_wrapper_linker.patch", "rules_rust_windows_build_script_runner_paths.patch", "rules_rust_windows_exec_bin_target.patch", diff --git a/patches/llvm_windows_arm64_powl.patch b/patches/llvm_windows_arm64_powl.patch index 5e12bd7d3b..0fd33c9766 100644 --- a/patches/llvm_windows_arm64_powl.patch +++ b/patches/llvm_windows_arm64_powl.patch @@ -15,10 +15,11 @@ diff --git a/runtimes/mingw/crt_sources.bzl b/runtimes/mingw/crt_sources.bzl diff --git a/toolchain/args/windows/BUILD.bazel b/toolchain/args/windows/BUILD.bazel --- a/toolchain/args/windows/BUILD.bazel +++ b/toolchain/args/windows/BUILD.bazel -@@ -49,5 +49,6 @@ cc_args( +@@ -45,5 +45,6 @@ cc_args( + args = [ + "-L{mingw_import_library_search_path}", + "-L{mingw_crt_library_search_path}", +- + "-lmingwex", - # For now, we force on ucrt. - # TODO(cerisier): make this either a constraint or a build setting - "-lucrt", - ], - data = [ ++ + # Clang will respect the user's chosen CRT variant. diff --git a/patches/llvm_windows_symlink_extract.patch b/patches/llvm_windows_mingw_compat.patch similarity index 51% rename from patches/llvm_windows_symlink_extract.patch rename to patches/llvm_windows_mingw_compat.patch index 9f548636b2..66cf4e7b34 100644 --- a/patches/llvm_windows_symlink_extract.patch +++ b/patches/llvm_windows_mingw_compat.patch @@ -1,26 +1,11 @@ -diff --git a/extensions/llvm_source.bzl b/extensions/llvm_source.bzl -index 89dcf81..cf27c92 100644 ---- a/extensions/llvm_source.bzl -+++ b/extensions/llvm_source.bzl -@@ -83,6 +83,13 @@ def _llvm_source_archive_excludes(): - "offload", - "libc/docs", - "libc/utils/gn", -+ # These entries are unix symlinks in the upstream source tarball. -+ # Windows tar.exe cannot materialize them during repository extraction, -+ # but nothing in our Bazel usage needs the symlink entrypoints. -+ "llvm/utils/mlgo-utils/extract_ir.py", -+ "llvm/utils/mlgo-utils/make_corpus.py", -+ "llvm/utils/mlgo-utils/combine_training_corpus.py", -+ "llvm/docs/_themes/llvm-theme", - ] - - test_docs_subprojects = [ +# What: provide MinGW compatibility archives required by Windows gnullvm links. +# Why: clang and prebuilt MSVC archives can request these names even though the +# hermetic MinGW runtime does not publish them by default. + diff --git a/runtimes/mingw/BUILD.bazel b/runtimes/mingw/BUILD.bazel -index ebd99db..9eb5d5b 100644 --- a/runtimes/mingw/BUILD.bazel +++ b/runtimes/mingw/BUILD.bazel -@@ -334,6 +334,30 @@ stub_library( +@@ -386,14 +386,42 @@ stub_library( name = "stdc++", ) @@ -51,14 +36,15 @@ index ebd99db..9eb5d5b 100644 copy_to_directory( name = "mingw_crt_library_search_directory", srcs = [ -@@ -344,6 +364,10 @@ copy_to_directory( ++ ":libcmt", + ":m", + ":mingw32", + ":mingwex", ":moldname", + ":oldnames", ":pthread", - ":stdc++", + ":ssp", + ":ssp_nonshared", -+ ":libcmt", - ":ucrt", - ":ucrtbase", - ":ucrtbased", + ":stdc++", + ":uuid", + ":winpthread", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03ff4643ab..2d4c6f3497 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,9 +7,12 @@ settings: overrides: '@modelcontextprotocol/sdk': 1.26.0 braces: ^3.0.3 + esbuild: 0.28.1 + fast-uri: 3.1.1 flatted: 3.4.2 glob@10.4.5: 10.5.0 handlebars: 4.7.9 + hono: 4.12.25 micromatch: ^4.0.8 minimatch@3.1.2: 3.1.4 minimatch@9.0.5: 9.0.7 @@ -62,10 +65,10 @@ importers: version: 3.6.2 ts-jest: specifier: ^29.3.4 - version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) + version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) ts-jest-mock-import-meta: specifier: ^1.3.1 - version: 1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)) + version: 1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.18)(typescript@5.9.2) @@ -256,158 +259,158 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@esbuild/aix-ppc64@0.25.10': - resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.10': - resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.10': - resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.10': - resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.10': - resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.10': - resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.10': - resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.10': - resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.10': - resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.10': - resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.10': - resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.10': - resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.10': - resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.10': - resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.10': - resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.10': - resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.10': - resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.10': - resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.10': - resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.10': - resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.10': - resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.10': - resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.10': - resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.10': - resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.10': - resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.10': - resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -454,7 +457,7 @@ packages: resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.25 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1000,7 +1003,7 @@ packages: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.18' + esbuild: 0.28.1 bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -1201,8 +1204,8 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.25.10: - resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -1340,8 +1343,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.1: + resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1483,8 +1486,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.12.12: - resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -2750,82 +2753,82 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@esbuild/aix-ppc64@0.25.10': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.10': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.10': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.10': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.10': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.10': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.10': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.10': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.10': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.10': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.10': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.10': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.10': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.10': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.10': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.10': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.10': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.10': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.10': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.10': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.10': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.10': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.10': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.10': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.10': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.10': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.0(eslint@9.36.0)': @@ -2872,9 +2875,9 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@hono/node-server@1.19.13(hono@4.12.12)': + '@hono/node-server@1.19.13(hono@4.12.25)': dependencies: - hono: 4.12.12 + hono: 4.12.25 '@humanfs/core@0.19.1': {} @@ -3094,7 +3097,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.12) + '@hono/node-server': 1.19.13(hono@4.12.25) ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 @@ -3104,7 +3107,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.12 + hono: 4.12.25 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.0 @@ -3400,7 +3403,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.1 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3541,9 +3544,9 @@ snapshots: buffer-from@1.1.2: {} - bundle-require@5.1.0(esbuild@0.25.10): + bundle-require@5.1.0(esbuild@0.28.1): dependencies: - esbuild: 0.25.10 + esbuild: 0.28.1 load-tsconfig: 0.2.5 bytes@3.1.2: {} @@ -3697,34 +3700,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.25.10: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.10 - '@esbuild/android-arm': 0.25.10 - '@esbuild/android-arm64': 0.25.10 - '@esbuild/android-x64': 0.25.10 - '@esbuild/darwin-arm64': 0.25.10 - '@esbuild/darwin-x64': 0.25.10 - '@esbuild/freebsd-arm64': 0.25.10 - '@esbuild/freebsd-x64': 0.25.10 - '@esbuild/linux-arm': 0.25.10 - '@esbuild/linux-arm64': 0.25.10 - '@esbuild/linux-ia32': 0.25.10 - '@esbuild/linux-loong64': 0.25.10 - '@esbuild/linux-mips64el': 0.25.10 - '@esbuild/linux-ppc64': 0.25.10 - '@esbuild/linux-riscv64': 0.25.10 - '@esbuild/linux-s390x': 0.25.10 - '@esbuild/linux-x64': 0.25.10 - '@esbuild/netbsd-arm64': 0.25.10 - '@esbuild/netbsd-x64': 0.25.10 - '@esbuild/openbsd-arm64': 0.25.10 - '@esbuild/openbsd-x64': 0.25.10 - '@esbuild/openharmony-arm64': 0.25.10 - '@esbuild/sunos-x64': 0.25.10 - '@esbuild/win32-arm64': 0.25.10 - '@esbuild/win32-ia32': 0.25.10 - '@esbuild/win32-x64': 0.25.10 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -3904,7 +3907,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.1: {} fastq@1.19.1: dependencies: @@ -4053,7 +4056,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.12: {} + hono@4.12.25: {} html-escaper@2.0.2: {} @@ -5047,11 +5050,11 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest-mock-import-meta@1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)): + ts-jest-mock-import-meta@1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)): dependencies: - ts-jest: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) + ts-jest: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) - ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2): + ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -5069,7 +5072,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.4) - esbuild: 0.25.10 + esbuild: 0.28.1 jest-util: 29.7.0 ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2): @@ -5092,12 +5095,12 @@ snapshots: tsup@8.5.0(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.1): dependencies: - bundle-require: 5.1.0(esbuild@0.25.10) + bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 debug: 4.4.3 - esbuild: 0.25.10 + esbuild: 0.28.1 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 diff --git a/scripts/format.py b/scripts/format.py index 6fa5aab699..9310254f52 100644 --- a/scripts/format.py +++ b/scripts/format.py @@ -18,7 +18,6 @@ REPO_ROOT = Path(__file__).resolve().parents[1] class Command: args: tuple[str, ...] cwd: Path = REPO_ROOT - discard_stderr: bool = False @dataclass(frozen=True) @@ -45,13 +44,7 @@ def rust_formatter_group(*, check: bool) -> FormatterGroup: args = ["cargo", "fmt", "--", "--config", "imports_granularity=Item"] if check: args.append("--check") - # Stable rustfmt repeats a nightly-only `imports_granularity` warning - # for each crate, so suppress that expected stderr noise. - command = Command( - tuple(args), - REPO_ROOT / "codex-rs", - discard_stderr=True, - ) + command = Command(tuple(args), REPO_ROOT / "codex-rs") return FormatterGroup("Rust", (command,)) @@ -152,31 +145,27 @@ def formatter_groups(*, check: bool) -> tuple[FormatterGroup, ...]: def run_formatter_group(group: FormatterGroup) -> FormatterResult: """Run one formatter group sequentially and return its buffered output.""" - output: list[str] = [] for command in group.commands: - output.append(f"$ {shlex.join(command.args)}\n") try: process = subprocess.run( command.args, cwd=command.cwd, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL - if command.discard_stderr - else subprocess.STDOUT, + stderr=subprocess.STDOUT, text=True, check=False, ) except OSError as error: - output.append(f"{error}\n") - return FormatterResult(group.name, "".join(output), 1) + output = f"$ {shlex.join(command.args)}\n{error}\n" + return FormatterResult(group.name, output, 1) - output.append(process.stdout) - if process.stdout and not process.stdout.endswith("\n"): - output.append("\n") if process.returncode != 0: - return FormatterResult(group.name, "".join(output), process.returncode) + output = f"$ {shlex.join(command.args)}\n{process.stdout}" + if process.stdout and not process.stdout.endswith("\n"): + output += "\n" + return FormatterResult(group.name, output, process.returncode) - return FormatterResult(group.name, "".join(output), 0) + return FormatterResult(group.name, "", 0) def main() -> int: @@ -191,16 +180,13 @@ def main() -> int: failures: list[str] = [] with ThreadPoolExecutor(max_workers=len(groups)) as executor: - futures = {} - for group in groups: - print(f"Starting {group.name} formatter...", flush=True) - futures[executor.submit(run_formatter_group, group)] = group.name + futures = [executor.submit(run_formatter_group, group) for group in groups] for future in as_completed(futures): result = future.result() - print(f"==> {result.name} formatter finished") - print(result.output, end="") if result.returncode != 0: failures.append(result.name) + print(f"==> {result.name} formatter failed", file=sys.stderr) + print(result.output, end="", file=sys.stderr) if failures: print(f"Formatting failed: {', '.join(failures)}", file=sys.stderr) diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index f003a28511..f253185dc5 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -245,6 +245,9 @@ Input = list[InputItem] | InputItem RunInput = Input | str ``` +Use `ImageInput` with a base64-encoded `data:image/...` URL. HTTP and HTTPS image URLs are +deprecated; download remote images and pass their local paths with `LocalImageInput` instead. + Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted: `thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`. diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py index c3237e320e..8673e455a3 100644 --- a/sdk/python/examples/07_image_and_text/async.py +++ b/sdk/python/examples/07_image_and_text/async.py @@ -5,7 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ensure_local_sdk_src, runtime_config +from _bootstrap import ensure_local_sdk_src, generated_sample_image_data_url, runtime_config ensure_local_sdk_src() @@ -13,7 +13,7 @@ import asyncio from openai_codex import AsyncCodex, ImageInput, TextInput -REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" +IMAGE_DATA_URL = generated_sample_image_data_url() async def main() -> None: @@ -24,7 +24,7 @@ async def main() -> None: turn = await thread.turn( [ TextInput("What is in this image? Give 3 bullets."), - ImageInput(REMOTE_IMAGE_URL), + ImageInput(IMAGE_DATA_URL), ] ) result = await turn.run() diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py index f7402f18c9..1b20f8462d 100644 --- a/sdk/python/examples/07_image_and_text/sync.py +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -5,20 +5,20 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ensure_local_sdk_src, runtime_config +from _bootstrap import ensure_local_sdk_src, generated_sample_image_data_url, runtime_config ensure_local_sdk_src() from openai_codex import Codex, ImageInput, TextInput -REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" +IMAGE_DATA_URL = generated_sample_image_data_url() with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) result = thread.turn( [ TextInput("What is in this image? Give 3 bullets."), - ImageInput(REMOTE_IMAGE_URL), + ImageInput(IMAGE_DATA_URL), ] ).run() diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 719fb29a41..a86580cc76 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -72,7 +72,7 @@ python examples/01_quickstart_constructor/async.py - `06_thread_lifecycle_and_controls/` - thread lifecycle + control calls - `07_image_and_text/` - - remote image URL + text multimodal turn + - image data URL + text multimodal turn - `08_local_image_and_text/` - local image + text multimodal turn using a generated temporary sample image - `09_async_parity/` diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py index fde7a32c00..88039f4b9c 100644 --- a/sdk/python/examples/_bootstrap.py +++ b/sdk/python/examples/_bootstrap.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import contextlib import importlib.util import sys @@ -95,6 +96,11 @@ def _generated_sample_png_bytes() -> bytes: ) +def generated_sample_image_data_url() -> str: + encoded = base64.b64encode(_generated_sample_png_bytes()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + @contextlib.contextmanager def temporary_sample_image_path() -> Iterator[Path]: with tempfile.TemporaryDirectory(prefix="codex-python-example-image-") as temp_root: diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 1bb9bf731f..3f84cf0675 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -103,7 +103,7 @@ "outputs": [], "source": [ "# Cell 2: imports (public only)\n", - "from _bootstrap import server_label\n", + "from _bootstrap import generated_sample_image_data_url, server_label\n", "from openai_codex import (\n", " AsyncCodex,\n", " Codex,\n", @@ -349,14 +349,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Cell 6: multimodal with remote image\n", - "remote_image_url = 'https://raw.githubusercontent.com/github/explore/main/topics/python/python.png'\n", + "# Cell 6: multimodal with an image data URL\n", + "image_data_url = generated_sample_image_data_url()\n", "\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " result = thread.turn([\n", " TextInput('What do you see in this image? 3 bullets.'),\n", - " ImageInput(remote_image_url),\n", + " ImageInput(image_data_url),\n", " ]).run()\n", " print('status:', result.status)\n", " print(result.final_response)\n" diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index a4af5d605e..f3b97c6b6f 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -503,6 +503,33 @@ def _annotate_schema(value: Any, base: str | None = None) -> None: _annotate_schema(child, base) +def _make_chatgpt_account_email_nullable(schema: dict[str, Any]) -> None: + definitions = schema.get("definitions") + if not isinstance(definitions, dict): + raise RuntimeError("Schema bundle is missing definitions") + + account = definitions.get("Account") + if not isinstance(account, dict): + raise RuntimeError("Schema bundle is missing the Account definition") + + for variant in account.get("oneOf", []): + if not isinstance(variant, dict): + continue + properties = variant.get("properties") + if not isinstance(properties, dict): + continue + account_type = properties.get("type") + if not isinstance(account_type, dict) or account_type.get("enum") != ["chatgpt"]: + continue + email = properties.get("email") + if not isinstance(email, dict): + raise RuntimeError("ChatGPT account schema is missing email") + email["type"] = ["string", "null"] + return + + raise RuntimeError("Schema bundle is missing the ChatGPT account variant") + + def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: """Generate app-server schemas by invoking the installed pinned runtime binary.""" codex_path = pinned_runtime_codex_path() @@ -525,6 +552,7 @@ def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: def _normalized_schema_bundle_text(schema_dir: Path) -> str: """Normalize the schema bundle before feeding it to the Python type generator.""" schema = json.loads(schema_bundle_path(schema_dir).read_text()) + _make_chatgpt_account_email_nullable(schema) definitions = schema.get("definitions", {}) if isinstance(definitions, dict): for definition in definitions.values(): @@ -580,9 +608,34 @@ def generate_v2_all(schema_dir: Path) -> None: ], cwd=sdk_root(), ) + _require_nullable_chatgpt_account_email(out_path) _normalize_generated_timestamps(out_path) +def _require_nullable_chatgpt_account_email(out_path: Path) -> None: + """Preserve required-but-nullable email semantics in the generated SDK model.""" + source = out_path.read_text() + class_start = source.find("class ChatgptAccount(BaseModel):") + if class_start == -1: + raise RuntimeError("Generated SDK is missing ChatgptAccount") + class_end = source.find("\n\nclass ", class_start) + if class_end == -1: + class_end = len(source) + + class_source = source[class_start:class_end] + nullable_with_default = " email: str | None = None" + if class_source.count(nullable_with_default) != 1: + raise RuntimeError( + "Generated ChatgptAccount email did not have the expected nullable shape" + ) + class_source = class_source.replace( + nullable_with_default, + " email: str | None", + 1, + ) + out_path.write_text(source[:class_start] + class_source + source[class_end:]) + + def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]: """Map each server notification method to its generated payload model class.""" server_notifications = json.loads((schema_dir / "ServerNotification.json").read_text()) diff --git a/sdk/python/src/openai_codex/_inputs.py b/sdk/python/src/openai_codex/_inputs.py index a6e5e7b528..0a1fbe1100 100644 --- a/sdk/python/src/openai_codex/_inputs.py +++ b/sdk/python/src/openai_codex/_inputs.py @@ -14,7 +14,7 @@ class TextInput: @dataclass(slots=True) class ImageInput: - """Remote image URL supplied as turn input.""" + """Image data URL supplied as turn input.""" url: str diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 15ede1801c..c024a2c8c0 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -4654,7 +4654,7 @@ class ChatgptAccount(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - email: str + email: str | None plan_type: Annotated[PlanType, Field(alias="planType")] type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")] diff --git a/sdk/python/tests/test_app_server_inputs.py b/sdk/python/tests/test_app_server_inputs.py index 56505e4817..29b8fc7726 100644 --- a/sdk/python/tests/test_app_server_inputs.py +++ b/sdk/python/tests/test_app_server_inputs.py @@ -1,40 +1,45 @@ from __future__ import annotations +import base64 + from app_server_harness import AppServerHarness from app_server_helpers import TINY_PNG_BYTES from openai_codex import Codex, ImageInput, LocalImageInput, SkillInput, TextInput -def test_remote_image_input_reaches_responses_api( +def test_data_url_image_input_reaches_responses_api( tmp_path, ) -> None: - """Remote image inputs should survive the SDK and app-server boundary.""" - remote_image_url = "https://example.com/codex.png" + """Data URL image inputs should survive the SDK and app-server boundary.""" + image_data_url = "data:image/png;base64," + base64.b64encode(TINY_PNG_BYTES).decode("ascii") with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_assistant_message( - "remote image received", - response_id="remote-image", + "data URL image received", + response_id="data-url-image", ) with Codex(config=harness.app_server_config()) as codex: result = codex.thread_start().run( [ - TextInput("Describe the remote image."), - ImageInput(remote_image_url), + TextInput("Describe the data URL image."), + ImageInput(image_data_url), ] ) request = harness.responses.single_request() assert { "final_response": result.final_response, - "contains_user_prompt": "Describe the remote image." in request.message_input_texts("user"), - "image_urls": request.message_image_urls("user"), + "contains_user_prompt": "Describe the data URL image." + in request.message_input_texts("user"), + "image_url_is_png_data_url": request.message_image_urls("user")[-1].startswith( + "data:image/png;base64," + ), } == { - "final_response": "remote image received", + "final_response": "data URL image received", "contains_user_prompt": True, - "image_urls": [remote_image_url], + "image_url_is_png_data_url": True, } diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 5242600357..301a75abac 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -10,6 +10,7 @@ from pathlib import Path import pytest import tomllib +from pydantic import ValidationError ROOT = Path(__file__).resolve().parents[1] @@ -111,9 +112,9 @@ def test_root_fmt_recipes_use_shared_formatter_driver() -> None: "fmt_comment": ( "# Format the justfile, Rust, Bazel/Starlark, Python SDK code, and Python scripts." ), - "fmt_commands": ["{{ python }} ../scripts/format.py"], + "fmt_commands": ["@{{ python }} ../scripts/format.py"], "fmt_check_comment": "# Check formatting without modifying files.", - "fmt_check_commands": ["{{ python }} ../scripts/format.py --check"], + "fmt_check_commands": ["@{{ python }} ../scripts/format.py --check"], } assert actual == expected, ( @@ -244,6 +245,75 @@ def test_root_format_driver_covers_all_formatter_groups( ] +def test_root_format_driver_discards_successful_command_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + script = _load_root_format_script_module() + processes = iter( + ( + script.subprocess.CompletedProcess(("first",), 0, "routine output\n"), + script.subprocess.CompletedProcess(("second",), 2, "failure output\n"), + ) + ) + monkeypatch.setattr(script.subprocess, "run", lambda *args, **kwargs: next(processes)) + group = script.FormatterGroup( + "Test", + (script.Command(("first",)), script.Command(("second",))), + ) + + assert script.run_formatter_group(group) == script.FormatterResult( + "Test", + "$ second\nfailure output\n", + 2, + ) + + +def test_root_format_driver_is_silent_when_all_formatters_succeed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + script = _load_root_format_script_module() + groups = (script.FormatterGroup("Quiet", ()),) + monkeypatch.setattr(script, "formatter_groups", lambda *, check: groups) + monkeypatch.setattr( + script, + "run_formatter_group", + lambda group: script.FormatterResult(group.name, "hidden output\n", 0), + ) + monkeypatch.setattr(sys, "argv", ["format.py"]) + + assert script.main() == 0 + captured = capsys.readouterr() + assert (captured.out, captured.err) == ("", "") + + +def test_root_format_driver_reports_only_failed_formatters( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + script = _load_root_format_script_module() + groups = ( + script.FormatterGroup("Quiet", ()), + script.FormatterGroup("Broken", ()), + ) + monkeypatch.setattr(script, "formatter_groups", lambda *, check: groups) + + def fake_run(group): + if group.name == "Broken": + return script.FormatterResult(group.name, "$ broken\nfailure output\n", 2) + return script.FormatterResult(group.name, "hidden output\n", 0) + + monkeypatch.setattr(script, "run_formatter_group", fake_run) + monkeypatch.setattr(sys, "argv", ["format.py"]) + + assert script.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == ( + "==> Broken formatter failed\n$ broken\nfailure output\nFormatting failed: Broken\n" + ) + + def test_generate_types_wires_all_generation_steps() -> None: """The type generation command should refresh every schema-derived artifact.""" source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text() @@ -306,6 +376,32 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( ] +def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None: + script = _load_update_script_module() + schema = { + "definitions": { + "Account": { + "oneOf": [ + { + "properties": { + "email": {"type": "string"}, + "type": {"enum": ["chatgpt"], "type": "string"}, + }, + "required": ["email", "type"], + "type": "object", + } + ] + } + } + } + + script._make_chatgpt_account_email_nullable(schema) + + chatgpt_account = schema["definitions"]["Account"]["oneOf"][0] + assert chatgpt_account["properties"]["email"]["type"] == ["string", "null"] + assert "email" in chatgpt_account["required"] + + def test_python_codegen_schema_annotation_adds_stable_variant_titles( tmp_path: Path, ) -> None: @@ -350,6 +446,17 @@ def test_generate_v2_all_uses_titles_for_generated_names() -> None: assert "ruff-format" in source +def test_generated_chatgpt_account_email_is_required_nullable() -> None: + from openai_codex.generated.v2_all import ChatgptAccount + + account = ChatgptAccount.model_validate({"email": None, "planType": "pro", "type": "chatgpt"}) + assert account.email is None + assert ChatgptAccount.model_fields["email"].is_required() + + with pytest.raises(ValidationError): + ChatgptAccount.model_validate({"planType": "pro", "type": "chatgpt"}) + + def test_runtime_package_template_has_no_checked_in_binaries() -> None: runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin" assert sorted(