diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index e604ce79bf1e..87be423d58b6 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -9,10 +9,35 @@ Apply this guidance when defining new types. Change existing code only when expl and keep edits minimal and proportional. Treat these rules as the target state of an ongoing migration; if compliance is difficult, ask the user how to proceed. -- In app-server protocol types, use `ApiPathString` for backwards compatibility during the URI +- In app-server protocol types, use `LegacyAppPathString` for backwards compatibility during the URI migration. At the protocol boundary, convert it to `PathUri` and use `PathUri` internally. For host-local logic, such as some config values, use `AbsolutePathBuf` or `PathBuf` instead. - In exec-server protocol types, use `PathUri`. Internally, use `PathUri` or `AbsolutePathBuf` as appropriate. - In dependencies shared by both servers, use `PathUri` or separate APIs that decouple their use cases. +- Tool call arguments that the model is expected to generate should be deserialized as regular + `String`s with feature-specific path handling code. + +## Migration requirements + +Keep these requirements in mind while migrating code to conform with the above guidelines: + +* existing app-server clients keep sending and receiving legacy native-path strings +* app-server can retain and manipulate foreign-platform path URIs +* exec-server APIs use file:// URIs +* local-only operation must not change model-visible text +* model tool arguments may contain raw relative or absolute paths for any OS +* path reasoning must work before the related environment has come online +* URIs cannot explicitly encode the executor’s path convention or operating system +* users must not configure the environment’s OS/path convention explicitly +* URIs should not yet be stored in rollouts, databases, or other persistent storage +* path conversion errors: fail-closed for security-relevant paths, fail-open for UI/diagnostics +* prefer small focused methods on `PathUri` or `LegacyAppPathString` over local helpers +* represent `PathUri` values as URIs in diagnostics + +It is OK if the conversion between paths and URIs is somewhat lossy as long as it will do the right +thing for real users. + +Migrating to URIs should not add significant new failure modes. We will need to surface errors in +some places that were previously infallible but it should be kept to a minimum. diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 3bbff22d2883..f3ddb0f3128e 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -23,6 +23,8 @@ jobs: # Upstream PRs also use the sharded Windows cross-compiled test jobs below. # Post-merge pushes to main run the native Windows test job for broader # Windows signal without putting PR latency back on the critical path. + # Code-mode unit tests run on every Bazel target. When authenticated RBE + # is available, the Windows-cross shards exercise the source-built V8 path. timeout-minutes: 30 strategy: fail-fast: false @@ -118,10 +120,6 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # Keep V8-backed code-mode tests out of the ordinary macOS/Linux - # legs; authenticated Windows-cross shards below exercise the - # source-built gnullvm V8 path. - -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) @@ -329,9 +327,6 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # Keep this job broad and cheap; authenticated Windows-cross jobs - # add source-built V8-backed code-mode coverage. - -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) diff --git a/AGENTS.md b/AGENTS.md index 730df0b88108..0cb869c23af4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,7 @@ Codex maintains a context (history of messages) that is sent to the model in inf Search for breaking changes in external integration surfaces: - app-server APIs +- raw response item events (`rawResponseItem/*`), even while experimental - CLI parameters - configuration loading - resuming sessions from existing rollouts diff --git a/MODULE.bazel b/MODULE.bazel index e9ec662d092a..a24f93afd81d 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, diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 8bd9dd2ae3fa..1f9f1ece6b26 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", @@ -984,8 +988,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\"]}}", @@ -1195,7 +1199,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\":[]}}", @@ -1878,6 +1881,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/bazel/rules/testing/wine/src/lib.rs b/bazel/rules/testing/wine/src/lib.rs index 3b99f5870b5e..76cb029e17dc 100644 --- a/bazel/rules/testing/wine/src/lib.rs +++ b/bazel/rules/testing/wine/src/lib.rs @@ -105,6 +105,14 @@ impl WineTestCommand { } impl WineTestProcess { + /// Returns the host path to this process's isolated Wine prefix. + pub fn prefix_path(&self) -> &Path { + let Some(processes) = self.processes.as_ref() else { + panic!("Wine process guard is missing"); + }; + processes.prefix.path() + } + /// Takes the piped standard output of the Wine process. /// /// This may only be called once for a process created by diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0fb948fab218..5b870acff431 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -394,7 +394,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app_test_support" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "codex-agent-graph-store" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-protocol", "codex-state", @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "codex-agent-identity" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -1896,7 +1896,7 @@ dependencies = [ [[package]] name = "codex-analytics" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-app-server-protocol", "codex-git-utils", @@ -1917,7 +1917,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.141.0" +version = "0.142.0" dependencies = [ "ansi-to-tui", "pretty_assertions", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "assert_matches", @@ -1947,7 +1947,6 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-test", @@ -1961,7 +1960,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "app_test_support", @@ -2053,7 +2052,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -2080,7 +2079,7 @@ dependencies = [ [[package]] name = "codex-app-server-daemon" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2101,7 +2100,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -2129,7 +2128,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -2138,6 +2137,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-cli", + "pretty_assertions", "serde", "serde_json", "tokio", @@ -2150,7 +2150,7 @@ dependencies = [ [[package]] name = "codex-app-server-transport" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "axum", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "assert_cmd", @@ -2209,7 +2209,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-apply-patch", @@ -2229,7 +2229,7 @@ dependencies = [ [[package]] name = "codex-artifactory" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "pretty_assertions", @@ -2241,7 +2241,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.141.0" +version = "0.142.0" dependencies = [ "pretty_assertions", "tokio", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "codex-aws-auth" -version = "0.141.0" +version = "0.142.0" dependencies = [ "aws-config", "aws-credential-types", @@ -2265,7 +2265,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-api", @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.141.0" +version = "0.142.0" dependencies = [ "serde", "serde_json", @@ -2291,7 +2291,7 @@ dependencies = [ [[package]] name = "codex-bwrap" -version = "0.141.0" +version = "0.142.0" dependencies = [ "cc", "libc", @@ -2300,7 +2300,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -2322,7 +2322,7 @@ dependencies = [ [[package]] name = "codex-cicd-artifacts" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-artifactory", @@ -2335,7 +2335,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "assert_cmd", @@ -2413,7 +2413,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.141.0" +version = "0.142.0" dependencies = [ "bytes", "codex-utils-cargo-bin", @@ -2443,10 +2443,11 @@ dependencies = [ [[package]] name = "codex-cloud-config" -version = "0.141.0" +version = "0.142.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-agent-identity", "codex-backend-client", "codex-config", "codex-core", @@ -2466,7 +2467,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -2497,7 +2498,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -2511,7 +2512,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-mock-client" -version = "0.141.0" +version = "0.142.0" dependencies = [ "chrono", "codex-cloud-tasks-client", @@ -2520,7 +2521,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-code-mode-protocol", "codex-protocol", @@ -2535,11 +2536,11 @@ dependencies = [ [[package]] name = "codex-code-mode-host" -version = "0.141.0" +version = "0.142.0" [[package]] name = "codex-code-mode-protocol" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-protocol", "pretty_assertions", @@ -2551,11 +2552,11 @@ dependencies = [ [[package]] name = "codex-collaboration-mode-templates" -version = "0.141.0" +version = "0.142.0" [[package]] name = "codex-config" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -2604,7 +2605,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2621,7 +2622,7 @@ dependencies = [ [[package]] name = "codex-context-fragments" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-protocol", "codex-utils-string", @@ -2629,7 +2630,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "arc-swap", @@ -2755,7 +2756,7 @@ dependencies = [ [[package]] name = "codex-core-api" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-analytics", "codex-app-server-protocol", @@ -2775,7 +2776,7 @@ dependencies = [ [[package]] name = "codex-core-plugins" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -2792,6 +2793,7 @@ dependencies = [ "codex-otel", "codex-plugin", "codex-protocol", + "codex-tools", "codex-utils-absolute-path", "codex-utils-path-uri", "codex-utils-plugins", @@ -2819,7 +2821,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-analytics", @@ -2839,6 +2841,7 @@ dependencies = [ "codex-utils-plugins", "dirs", "dunce", + "futures", "pretty_assertions", "serde", "serde_json", @@ -2853,7 +2856,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "assert_cmd", @@ -2899,7 +2902,7 @@ dependencies = [ [[package]] name = "codex-exec-output-compaction" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-utils-output-truncation", "pretty_assertions", @@ -2908,7 +2911,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "arc-swap", @@ -2931,6 +2934,7 @@ dependencies = [ "ctor 0.6.3", "futures", "http 1.4.0", + "libc", "pretty_assertions", "prost 0.14.3", "reqwest 0.12.28", @@ -2946,12 +2950,13 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", "tracing", "uuid", + "windows-sys 0.52.0", "wiremock", ] [[package]] name = "codex-execpolicy" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -2968,7 +2973,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.141.0" +version = "0.142.0" dependencies = [ "allocative", "anyhow", @@ -2988,7 +2993,7 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.141.0" +version = "0.142.0" dependencies = [ "proc-macro2", "quote", @@ -2997,7 +3002,7 @@ dependencies = [ [[package]] name = "codex-extension-api" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-config", "codex-context-fragments", @@ -3010,7 +3015,7 @@ dependencies = [ [[package]] name = "codex-external-agent-migration" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-hooks", "pretty_assertions", @@ -3022,7 +3027,7 @@ dependencies = [ [[package]] name = "codex-external-agent-sessions" -version = "0.141.0" +version = "0.142.0" dependencies = [ "chrono", "codex-app-server-protocol", @@ -3036,7 +3041,7 @@ dependencies = [ [[package]] name = "codex-features" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-otel", "codex-protocol", @@ -3049,7 +3054,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-login", @@ -3062,7 +3067,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -3078,17 +3083,19 @@ dependencies = [ [[package]] name = "codex-file-system" -version = "0.141.0" +version = "0.142.0" dependencies = [ + "bytes", "codex-protocol", "codex-utils-absolute-path", "codex-utils-path-uri", + "futures", "serde", ] [[package]] name = "codex-file-watcher" -version = "0.141.0" +version = "0.142.0" dependencies = [ "notify", "pretty_assertions", @@ -3099,7 +3106,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -3124,7 +3131,7 @@ dependencies = [ [[package]] name = "codex-goal-extension" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -3146,7 +3153,7 @@ dependencies = [ [[package]] name = "codex-guardian" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-core", "codex-extension-api", @@ -3155,7 +3162,7 @@ dependencies = [ [[package]] name = "codex-home" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-extension-api", "codex-utils-absolute-path", @@ -3166,7 +3173,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -3189,7 +3196,7 @@ dependencies = [ [[package]] name = "codex-image-generation-extension" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-api", "codex-core", @@ -3212,7 +3219,7 @@ dependencies = [ [[package]] name = "codex-install-context" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-utils-absolute-path", "codex-utils-home-dir", @@ -3222,7 +3229,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.141.0" +version = "0.142.0" dependencies = [ "keyring", "tracing", @@ -3230,7 +3237,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.141.0" +version = "0.142.0" dependencies = [ "clap", "codex-core", @@ -3254,7 +3261,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-core", "codex-model-provider-info", @@ -3268,7 +3275,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3310,7 +3317,7 @@ dependencies = [ [[package]] name = "codex-mcp" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "arc-swap", @@ -3325,6 +3332,7 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-rmcp-client", + "codex-utils-path-uri", "codex-utils-plugins", "futures", "pretty_assertions", @@ -3343,7 +3351,7 @@ dependencies = [ [[package]] name = "codex-mcp-extension" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-config", "codex-core", @@ -3367,7 +3375,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-arg0", @@ -3400,7 +3408,7 @@ dependencies = [ [[package]] name = "codex-memories-extension" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-core", "codex-extension-api", @@ -3421,7 +3429,7 @@ dependencies = [ [[package]] name = "codex-memories-read" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-protocol", "codex-shell-command", @@ -3431,7 +3439,7 @@ dependencies = [ [[package]] name = "codex-memories-write" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -3468,7 +3476,7 @@ dependencies = [ [[package]] name = "codex-message-history" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-config", "memchr", @@ -3482,7 +3490,7 @@ dependencies = [ [[package]] name = "codex-model-provider" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-agent-identity", "codex-api", @@ -3505,7 +3513,7 @@ dependencies = [ [[package]] name = "codex-model-provider-info" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-api", "codex-app-server-protocol", @@ -3522,7 +3530,7 @@ dependencies = [ [[package]] name = "codex-model-router" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-config", "codex-protocol", @@ -3534,7 +3542,7 @@ dependencies = [ [[package]] name = "codex-models-manager" -version = "0.141.0" +version = "0.142.0" dependencies = [ "chrono", "codex-app-server-protocol", @@ -3554,7 +3562,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3574,6 +3582,8 @@ dependencies = [ "rama-tls-rustls", "rama-unix", "rustls-native-certs", + "schannel", + "security-framework 3.5.1", "serde", "serde_json", "sha2 0.10.9", @@ -3587,7 +3597,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.141.0" +version = "0.142.0" dependencies = [ "assert_matches", "async-stream", @@ -3607,7 +3617,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.141.0" +version = "0.142.0" dependencies = [ "chrono", "codex-api", @@ -3639,7 +3649,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-config", "codex-protocol", @@ -3651,7 +3661,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.141.0" +version = "0.142.0" dependencies = [ "libc", "pretty_assertions", @@ -3659,7 +3669,7 @@ dependencies = [ [[package]] name = "codex-prompts" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-context-fragments", @@ -3673,7 +3683,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chardetng", @@ -3714,7 +3724,7 @@ dependencies = [ [[package]] name = "codex-realtime-webrtc" -version = "0.141.0" +version = "0.142.0" dependencies = [ "libwebrtc", "thiserror 2.0.18", @@ -3723,7 +3733,7 @@ dependencies = [ [[package]] name = "codex-repo-ci" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-cicd-artifacts", @@ -3737,7 +3747,7 @@ dependencies = [ [[package]] name = "codex-response-debug-context" -version = "0.141.0" +version = "0.142.0" dependencies = [ "base64 0.22.1", "codex-api", @@ -3748,7 +3758,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -3765,7 +3775,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "axum", @@ -3807,7 +3817,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -3832,7 +3842,7 @@ dependencies = [ [[package]] name = "codex-rollout-trace" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-code-mode", @@ -3848,13 +3858,15 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-home-dir", "codex-utils-path-uri", + "codex-windows-sandbox", "dunce", "libc", "pretty_assertions", @@ -3869,7 +3881,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.141.0" +version = "0.142.0" dependencies = [ "age", "anyhow", @@ -3890,7 +3902,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3911,7 +3923,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -3931,7 +3943,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -3940,7 +3952,7 @@ dependencies = [ [[package]] name = "codex-skills-extension" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-core-skills", "codex-exec-server", @@ -3962,7 +3974,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "chrono", @@ -3988,7 +4000,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-uds", @@ -4000,7 +4012,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.141.0" +version = "0.142.0" dependencies = [ "pretty_assertions", "tracing", @@ -4008,7 +4020,7 @@ dependencies = [ [[package]] name = "codex-test-binary-support" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-arg0", "tempfile", @@ -4016,7 +4028,7 @@ dependencies = [ [[package]] name = "codex-thread-manager-sample" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "clap", @@ -4027,7 +4039,7 @@ dependencies = [ [[package]] name = "codex-thread-store" -version = "0.141.0" +version = "0.142.0" dependencies = [ "chrono", "codex-git-utils", @@ -4048,7 +4060,7 @@ dependencies = [ [[package]] name = "codex-tools" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-app-server-protocol", "codex-code-mode", @@ -4072,9 +4084,10 @@ dependencies = [ [[package]] name = "codex-tui" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", + "app_test_support", "arboard", "assert_matches", "base64 0.22.1", @@ -4182,7 +4195,7 @@ dependencies = [ [[package]] name = "codex-uds" -version = "0.141.0" +version = "0.142.0" dependencies = [ "async-io", "pretty_assertions", @@ -4194,7 +4207,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.141.0" +version = "0.142.0" dependencies = [ "dirs", "dunce", @@ -4208,14 +4221,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.141.0" +version = "0.142.0" dependencies = [ "lru 0.16.3", "sha1 0.10.6", @@ -4224,7 +4237,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.141.0" +version = "0.142.0" dependencies = [ "assert_cmd", "runfiles", @@ -4233,7 +4246,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.141.0" +version = "0.142.0" dependencies = [ "clap", "codex-protocol", @@ -4245,15 +4258,15 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.141.0" +version = "0.142.0" [[package]] name = "codex-utils-fuzzy-match" -version = "0.141.0" +version = "0.142.0" [[package]] name = "codex-utils-home-dir" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-utils-absolute-path", "dirs", @@ -4263,7 +4276,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.141.0" +version = "0.142.0" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -4276,7 +4289,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.141.0" +version = "0.142.0" dependencies = [ "pretty_assertions", "serde_json", @@ -4285,7 +4298,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-core", "codex-lmstudio", @@ -4295,7 +4308,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-protocol", "codex-utils-string", @@ -4304,7 +4317,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -4314,7 +4327,7 @@ dependencies = [ [[package]] name = "codex-utils-path-uri" -version = "0.141.0" +version = "0.142.0" dependencies = [ "base64 0.22.1", "codex-utils-absolute-path", @@ -4330,10 +4343,9 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-exec-server", - "codex-login", "codex-utils-absolute-path", "codex-utils-path-uri", "serde", @@ -4344,7 +4356,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "filedescriptor", @@ -4360,7 +4372,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.141.0" +version = "0.142.0" dependencies = [ "assert_matches", "thiserror 2.0.18", @@ -4370,14 +4382,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.141.0" +version = "0.142.0" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-core", "codex-model-provider-info", @@ -4388,7 +4400,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.141.0" +version = "0.142.0" dependencies = [ "core-foundation 0.9.4", "libc", @@ -4398,14 +4410,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.141.0" +version = "0.142.0" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.141.0" +version = "0.142.0" dependencies = [ "pretty_assertions", "regex-lite", @@ -4415,14 +4427,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.141.0" +version = "0.142.0" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.141.0" +version = "0.142.0" dependencies = [ "pretty_assertions", "v8", @@ -4430,7 +4442,7 @@ dependencies = [ [[package]] name = "codex-web-search-extension" -version = "0.141.0" +version = "0.142.0" dependencies = [ "codex-api", "codex-core", @@ -4449,7 +4461,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4706,7 +4718,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "assert_cmd", @@ -6097,8 +6109,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]] @@ -6786,7 +6798,7 @@ dependencies = [ "prodash", "thiserror 2.0.18", "walkdir", - "zlib-rs", + "zlib-rs 0.6.3", ] [[package]] @@ -8839,17 +8851,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" @@ -9115,7 +9116,7 @@ dependencies = [ [[package]] name = "mcp_test_support" -version = "0.141.0" +version = "0.142.0" dependencies = [ "anyhow", "codex-login", @@ -9526,7 +9527,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -10612,7 +10613,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -13723,7 +13724,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", @@ -14161,7 +14162,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", @@ -15922,6 +15923,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 8aa653928489..1c582a24c81e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -130,7 +130,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.141.0" +version = "0.142.0" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 @@ -556,11 +556,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 7aad81a34f18..267fdf53d8f4 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -1,4 +1,6 @@ use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; use std::time::Duration; use anyhow::Context; @@ -34,19 +36,64 @@ const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30); const AGENT_IDENTITY_JWKS_TIMEOUT: Duration = Duration::from_secs(10); const AGENT_IDENTITY_JWT_AUDIENCE: &str = "codex-app-server"; const AGENT_IDENTITY_JWT_ISSUER: &str = "https://chatgpt.com/codex-backend/agent-identity"; +const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15); +const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const STAGING_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.api.openai.org/api/accounts"; +const AGENT_IDENTITY_KEY_SEED_BYTES: usize = 64; +const AGENT_IDENTITY_KEY_DERIVATION_CONTEXT: &[u8] = b"codex-agent-identity-ed25519-v1"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ChatGptEnvironment { + #[default] + Production, + Staging, +} + +impl ChatGptEnvironment { + pub fn from_chatgpt_base_url(chatgpt_base_url: &str) -> Result { + match chatgpt_base_url.trim_end_matches('/') { + "https://chatgpt.com" + | "https://chatgpt.com/backend-api" + | "https://chatgpt.com/codex" + | "https://chatgpt.com/backend-api/codex" + | "https://chat.openai.com" + | "https://chat.openai.com/backend-api" + | "https://chat.openai.com/codex" + | "https://chat.openai.com/backend-api/codex" => Ok(Self::Production), + "https://chatgpt-staging.com" + | "https://chatgpt-staging.com/backend-api" + | "https://chatgpt-staging.com/codex" + | "https://chatgpt-staging.com/backend-api/codex" => Ok(Self::Staging), + _ => anyhow::bail!( + "Agent Identity only supports production and staging ChatGPT environments" + ), + } + } -/// Stored key material for a registered agent identity. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct AgentIdentityKey<'a> { - pub agent_runtime_id: &'a str, - pub private_key_pkcs8_base64: &'a str, + pub fn chatgpt_base_url(self) -> &'static str { + match self { + Self::Production => "https://chatgpt.com/backend-api", + Self::Staging => "https://chatgpt-staging.com/backend-api", + } + } + + pub fn agent_identity_authapi_base_url(self) -> &'static str { + match self { + Self::Production => PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL, + Self::Staging => STAGING_AGENT_IDENTITY_AUTHAPI_BASE_URL, + } + } } -/// Task binding to use when constructing a task-scoped AgentAssertion. +/// Borrowed durable signing material for a registered agent identity. +/// +/// This intentionally does not include a task id. Task ids are scoped to a +/// single Codex run, while the agent runtime id and private key are the +/// reusable identity material used to register and sign that run task. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct AgentTaskAuthorizationTarget<'a> { +pub struct AgentIdentityKey<'a> { pub agent_runtime_id: &'a str, - pub task_id: &'a str, + pub private_key_pkcs8_base64: &'a str, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -72,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, } @@ -103,23 +150,92 @@ struct RegisterTaskResponse { encrypted_task_id_camel: Option, } +#[derive(Debug, Serialize)] +struct RegisterAgentRequest { + abom: AgentBillOfMaterials, + agent_public_key: String, + capabilities: Vec, + ttl: Option, +} + +#[derive(Debug, Deserialize)] +struct RegisterAgentResponse { + agent_runtime_id: String, +} + +/// HTTP status failure returned by Agent Identity registration endpoints. +#[derive(Debug)] +pub struct AgentIdentityRegistrationHttpError { + operation: &'static str, + status: reqwest::StatusCode, + body: String, +} + +impl AgentIdentityRegistrationHttpError { + fn new(operation: &'static str, status: reqwest::StatusCode, body: String) -> Self { + Self { + operation, + status, + body, + } + } + + /// HTTP status returned by the registration endpoint. + pub fn status(&self) -> reqwest::StatusCode { + self.status + } +} + +impl fmt::Display for AgentIdentityRegistrationHttpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.body.is_empty() { + write!(f, "{} failed with status {}", self.operation, self.status) + } else { + write!( + f, + "{} failed with status {}: {}", + self.operation, self.status, self.body + ) + } + } +} + +impl StdError for AgentIdentityRegistrationHttpError {} + +/// Returns whether an Agent Identity registration error is safe to retry. +pub fn is_retryable_registration_error(error: &anyhow::Error) -> bool { + error.chain().any(is_retryable_registration_cause) +} + +fn is_retryable_registration_cause(cause: &(dyn StdError + 'static)) -> bool { + if let Some(error) = cause.downcast_ref::() { + return is_retryable_registration_status(error.status()); + } + + if let Some(error) = cause.downcast_ref::() { + if let Some(status) = error.status() { + return is_retryable_registration_status(status); + } + return error.is_timeout() || error.is_connect() || error.is_request(); + } + + false +} + +fn is_retryable_registration_status(status: reqwest::StatusCode) -> bool { + status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() +} + pub fn authorization_header_for_agent_task( key: AgentIdentityKey<'_>, - target: AgentTaskAuthorizationTarget<'_>, + task_id: &str, ) -> Result { - anyhow::ensure!( - key.agent_runtime_id == target.agent_runtime_id, - "agent task runtime {} does not match stored agent identity {}", - target.agent_runtime_id, - key.agent_runtime_id - ); - let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); let envelope = AgentAssertionEnvelope { - agent_runtime_id: target.agent_runtime_id.to_string(), - task_id: target.task_id.to_string(), + agent_runtime_id: key.agent_runtime_id.to_string(), + task_id: task_id.to_string(), timestamp: timestamp.clone(), - signature: sign_agent_assertion_payload(key, target.task_id, ×tamp)?, + signature: sign_agent_assertion_payload(key, task_id, ×tamp)?, }; let serialized_assertion = serialize_agent_assertion(&envelope)?; Ok(format!("AgentAssertion {serialized_assertion}")) @@ -127,10 +243,10 @@ pub fn authorization_header_for_agent_task( pub async fn fetch_agent_identity_jwks( client: &reqwest::Client, - chatgpt_base_url: &str, + agent_identity_jwt_base_url: &str, ) -> Result { let response = client - .get(agent_identity_jwks_url(chatgpt_base_url)) + .get(agent_identity_jwks_url(agent_identity_jwt_base_url)) .timeout(AGENT_IDENTITY_JWKS_TIMEOUT) .send() .await @@ -195,7 +311,7 @@ pub fn sign_task_registration_payload( pub async fn register_agent_task( client: &reqwest::Client, - chatgpt_base_url: &str, + agent_identity_authapi_base_url: &str, key: AgentIdentityKey<'_>, ) -> Result { let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); @@ -203,7 +319,7 @@ pub async fn register_agent_task( signature: sign_task_registration_payload(key, ×tamp)?, timestamp, }; - let url = agent_task_registration_url(chatgpt_base_url, key.agent_runtime_id); + let url = agent_task_registration_url(agent_identity_authapi_base_url, key.agent_runtime_id); let response = client .post(url) @@ -220,7 +336,12 @@ pub async fn register_agent_task( } else { body }; - anyhow::bail!("failed to register agent task with status {status}: {body}"); + return Err(AgentIdentityRegistrationHttpError::new( + "agent task registration", + status, + body, + ) + .into()); } let response = response @@ -231,6 +352,45 @@ pub async fn register_agent_task( task_id_from_register_task_response(key, response) } +pub async fn register_agent_identity( + client: &reqwest::Client, + agent_identity_authapi_base_url: &str, + access_token: &str, + is_fedramp_account: bool, + key_material: &GeneratedAgentKeyMaterial, + abom: AgentBillOfMaterials, + capabilities: Vec, +) -> Result { + let url = agent_registration_url(agent_identity_authapi_base_url); + let request = RegisterAgentRequest { + abom, + agent_public_key: key_material.public_key_ssh.clone(), + capabilities, + ttl: None, + }; + + let mut request_builder = client + .post(&url) + .bearer_auth(access_token) + .json(&request) + .timeout(AGENT_REGISTRATION_TIMEOUT); + if is_fedramp_account { + request_builder = request_builder.header("X-OpenAI-Fedramp", "true"); + } + + let response = request_builder + .send() + .await + .with_context(|| format!("failed to send agent identity registration request to {url}"))? + .error_for_status() + .with_context(|| format!("agent identity registration failed for {url}"))? + .json::() + .await + .with_context(|| format!("failed to parse agent identity response from {url}"))?; + + Ok(response.agent_runtime_id) +} + fn task_id_from_register_task_response( key: AgentIdentityKey<'_>, response: RegisterTaskResponse, @@ -260,10 +420,17 @@ pub fn decrypt_task_id_response( } pub fn generate_agent_key_material() -> Result { - let mut secret_key_bytes = [0u8; 32]; + let mut seed_material = [0u8; AGENT_IDENTITY_KEY_SEED_BYTES]; OsRng - .try_fill_bytes(&mut secret_key_bytes) - .context("failed to generate agent identity private key bytes")?; + .try_fill_bytes(&mut seed_material) + .context("failed to generate agent identity private key seed material")?; + // Ed25519 stores a 32-byte seed, so derive it from all sampled seed material. + let mut digest = Sha512::new(); + digest.update(AGENT_IDENTITY_KEY_DERIVATION_CONTEXT); + digest.update(seed_material); + let digest = digest.finalize(); + let mut secret_key_bytes = [0u8; 32]; + secret_key_bytes.copy_from_slice(&digest[..32]); let signing_key = SigningKey::from_bytes(&secret_key_bytes); let private_key_pkcs8 = signing_key .to_pkcs8_der() @@ -296,23 +463,22 @@ pub fn curve25519_secret_key_from_private_key_pkcs8_base64( Ok(curve25519_secret_key_from_signing_key(&signing_key)) } -pub fn agent_registration_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/v1/agent/register") -} - -pub fn agent_task_registration_url(chatgpt_base_url: &str, agent_runtime_id: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/v1/agent/{agent_runtime_id}/task/register") +pub fn agent_registration_url(agent_identity_authapi_base_url: &str) -> String { + agent_identity_authapi_url(agent_identity_authapi_base_url, "/v1/agent/register") } -pub fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/authenticate_app_v2") +pub fn agent_task_registration_url( + agent_identity_authapi_base_url: &str, + agent_runtime_id: &str, +) -> String { + agent_identity_authapi_url( + agent_identity_authapi_base_url, + &format!("/v1/agent/{agent_runtime_id}/task/register"), + ) } -pub fn agent_identity_jwks_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); +pub fn agent_identity_jwks_url(agent_identity_jwt_base_url: &str) -> String { + let trimmed = agent_identity_jwt_base_url.trim_end_matches('/'); if trimmed.contains("/backend-api") { format!("{trimmed}/wham/agent-identities/jwks") } else { @@ -320,15 +486,9 @@ pub fn agent_identity_jwks_url(chatgpt_base_url: &str) -> String { } } -pub fn agent_identity_request_id() -> Result { - let mut request_id_bytes = [0u8; 16]; - OsRng - .try_fill_bytes(&mut request_id_bytes) - .context("failed to generate agent identity request id")?; - Ok(format!( - "codex-agent-identity-{}", - URL_SAFE_NO_PAD.encode(request_id_bytes) - )) +fn agent_identity_authapi_url(agent_identity_authapi_base_url: &str, api_path: &str) -> String { + let base_url = agent_identity_authapi_base_url.trim_end_matches('/'); + format!("{base_url}{api_path}") } pub fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials { @@ -412,6 +572,24 @@ mod tests { use super::*; + #[test] + fn register_task_request_uses_single_run_task_shape() { + let request = RegisterTaskRequest { + timestamp: "2026-04-23T00:00:00Z".to_string(), + signature: "signature".to_string(), + }; + + let serialized = serde_json::to_value(request).expect("serialize request"); + + assert_eq!( + serialized, + serde_json::json!({ + "timestamp": "2026-04-23T00:00:00Z", + "signature": "signature", + }) + ); + } + #[test] fn authorization_header_for_agent_task_serializes_signed_agent_assertion() { let signing_key = SigningKey::from_bytes(&[7u8; 32]); @@ -422,13 +600,9 @@ mod tests { agent_runtime_id: "agent-123", private_key_pkcs8_base64: &BASE64_STANDARD.encode(private_key.as_bytes()), }; - let target = AgentTaskAuthorizationTarget { - agent_runtime_id: "agent-123", - task_id: "task-123", - }; - let header = - authorization_header_for_agent_task(key, target).expect("build agent assertion header"); + let header = authorization_header_for_agent_task(key, "task-123") + .expect("build agent assertion header"); let token = header .strip_prefix("AgentAssertion ") .expect("agent assertion scheme"); @@ -464,31 +638,6 @@ mod tests { .expect("signature should verify"); } - #[test] - fn authorization_header_for_agent_task_rejects_mismatched_runtime() { - let signing_key = SigningKey::from_bytes(&[7u8; 32]); - let private_key = signing_key - .to_pkcs8_der() - .expect("encode test key material"); - let private_key_pkcs8_base64 = BASE64_STANDARD.encode(private_key.as_bytes()); - let key = AgentIdentityKey { - agent_runtime_id: "agent-123", - private_key_pkcs8_base64: &private_key_pkcs8_base64, - }; - let target = AgentTaskAuthorizationTarget { - agent_runtime_id: "agent-456", - task_id: "task-123", - }; - - let error = authorization_header_for_agent_task(key, target) - .expect_err("runtime mismatch should fail"); - - assert_eq!( - error.to_string(), - "agent task runtime agent-456 does not match stored agent identity agent-123" - ); - } - #[test] fn decode_agent_identity_jwt_reads_claims() { let jwt = jwt_with_payload(serde_json::json!({ @@ -518,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!({ @@ -558,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, }; @@ -590,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, }; @@ -704,7 +873,98 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN } #[test] - fn agent_identity_jwks_url_uses_backend_api_base_url() { + fn chatgpt_environment_maps_known_urls_to_authapi() -> anyhow::Result<()> { + assert_eq!( + ChatGptEnvironment::from_chatgpt_base_url("https://chatgpt.com/backend-api/codex")?, + ChatGptEnvironment::Production + ); + assert_eq!( + ChatGptEnvironment::Production.agent_identity_authapi_base_url(), + "https://auth.openai.com/api/accounts" + ); + assert_eq!( + ChatGptEnvironment::from_chatgpt_base_url("https://chatgpt-staging.com/backend-api")?, + ChatGptEnvironment::Staging + ); + assert_eq!( + ChatGptEnvironment::Staging.agent_identity_authapi_base_url(), + "https://auth.api.openai.org/api/accounts" + ); + Ok(()) + } + + #[test] + fn chatgpt_environment_rejects_custom_urls() { + assert!(ChatGptEnvironment::from_chatgpt_base_url("http://localhost:8080").is_err(),); + } + + #[test] + fn agent_registration_url_appends_to_authapi_base_url() { + assert_eq!( + agent_registration_url("https://auth.openai.com/api/accounts"), + "https://auth.openai.com/api/accounts/v1/agent/register" + ); + assert_eq!( + agent_registration_url("http://localhost:8080"), + "http://localhost:8080/v1/agent/register" + ); + assert_eq!( + agent_registration_url("http://localhost:8080/backend-api"), + "http://localhost:8080/backend-api/v1/agent/register" + ); + } + + #[test] + fn agent_task_registration_url_appends_to_authapi_base_url() { + assert_eq!( + agent_task_registration_url("https://auth.openai.com/api/accounts", "agent-runtime-id"), + "https://auth.openai.com/api/accounts/v1/agent/agent-runtime-id/task/register" + ); + assert_eq!( + agent_task_registration_url( + "https://auth.openai.com/api/accounts/", + "agent-runtime-id" + ), + "https://auth.openai.com/api/accounts/v1/agent/agent-runtime-id/task/register" + ); + assert_eq!( + agent_task_registration_url("http://localhost:8080", "agent-runtime-id"), + "http://localhost:8080/v1/agent/agent-runtime-id/task/register" + ); + } + + #[test] + fn retryable_registration_error_accepts_429_and_5xx() { + let too_many_requests = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::TOO_MANY_REQUESTS, + "rate limited".to_string(), + )); + let unavailable = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::SERVICE_UNAVAILABLE, + "try later".to_string(), + )); + + assert!(is_retryable_registration_error(&too_many_requests)); + assert!(is_retryable_registration_error(&unavailable)); + } + + #[test] + fn retryable_registration_error_rejects_hard_failures() { + let forbidden = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::FORBIDDEN, + "not allowed".to_string(), + )); + let malformed = anyhow::anyhow!("failed to sign registration request"); + + assert!(!is_retryable_registration_error(&forbidden)); + assert!(!is_retryable_registration_error(&malformed)); + } + + #[test] + fn agent_identity_jwks_url_uses_agent_identity_jwt_route() { assert_eq!( agent_identity_jwks_url("https://chatgpt.com/backend-api"), "https://chatgpt.com/backend-api/wham/agent-identities/jwks" @@ -716,7 +976,7 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN } #[test] - fn agent_identity_jwks_url_uses_codex_api_base_url() { + fn agent_identity_jwks_url_uses_jwt_issuer_base_url() { assert_eq!( agent_identity_jwks_url("http://localhost:8080/api/codex"), "http://localhost:8080/api/codex/agent-identities/jwks" diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bf61e2134ada..04cac0cc0df0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -9,7 +9,11 @@ use crate::events::CodexCommandExecutionEventParams; use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -51,10 +55,13 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; use crate::facts::InputError; use crate::facts::InvocationType; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -178,6 +185,7 @@ fn sample_thread_with_metadata( model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -216,6 +224,7 @@ fn sample_thread_start_response( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } @@ -280,6 +289,7 @@ fn sample_thread_resume_response_with_source( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }) } @@ -766,6 +776,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -823,7 +834,7 @@ fn sample_command_execution_item_with_id( ThreadItem::CommandExecution { id: id.to_string(), command: "echo hi".to_string(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-1".to_string()), source: CommandExecutionSource::Agent, status, @@ -861,6 +872,7 @@ fn sample_command_approval_request(request_id: i64, approval_id: Option<&str>) - item_id: "item-1".to_string(), started_at_ms: 1_000, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: None, network_approval_context: None, command: Some("echo hi".to_string()), @@ -1660,6 +1672,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1856,6 +1869,7 @@ async fn compaction_event_ingests_custom_fact() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1984,6 +1998,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -3099,6 +3114,36 @@ fn plugin_management_event_serializes_expected_shape() { ); } +#[test] +fn plugin_install_failed_event_serializes_expected_shape() { + let event = TrackEventRequest::PluginInstallFailed(CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(sample_plugin_metadata()), + error_type: "store_io".to_string(), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin install failed event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "store_io" + } + }) + ); +} + #[test] fn plugin_management_event_can_use_remote_plugin_id_override() { let mut plugin = sample_plugin_metadata(); @@ -3466,6 +3511,191 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin: sample_plugin_metadata(), + error_type: "invalid_plugin".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "invalid_plugin" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact_without_detail() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + 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( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin, + error_type: "remote_catalog_unexpected_status".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "plugins~Plugin_00000000000000000000000000000000", + "plugin_name": "unknown", + "marketplace_name": "openai-curated-remote", + "has_skills": null, + "mcp_server_count": null, + "connector_ids": null, + "product_client_id": originator().value, + "error_type": "remote_catalog_unexpected_status" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_completed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "PLUGINS".to_string(), + success_count: 2, + failed_count: 1, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_complete", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "PLUGINS", + "success_count": 2, + "failed_count": 1, + "product_client_id": originator().value, + } + }]) + ); +} + +#[test] +fn external_agent_config_import_failure_event_serializes_expected_shape() { + let event = TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + product_client_id: Some(originator().value), + }, + }, + ); + + let payload = serde_json::to_value(&event).expect("serialize import failure event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_failure_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportFailure( + ExternalAgentConfigImportFailureInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }]) + ); +} + #[test] fn turn_event_serializes_expected_shape() { let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { @@ -3941,6 +4171,7 @@ async fn turn_event_counts_completed_tool_items() { tool: "search".to_string(), status, arguments: json!({}), + app_context: None, mcp_app_resource_uri: None, plugin_id: Some("sample@test".to_string()), result: None, diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e88c0114a016..24ff523c9ce1 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -11,8 +11,11 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; @@ -343,6 +346,33 @@ impl AnalyticsEventsClient { )); } + pub fn track_plugin_install_failed(&self, plugin: PluginTelemetryMetadata, error_type: String) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput { + plugin, + error_type, + }), + )); + } + + pub fn track_external_agent_config_import_completed( + &self, + input: ExternalAgentConfigImportCompletedInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input), + )); + } + + pub fn track_external_agent_config_import_failure( + &self, + input: ExternalAgentConfigImportFailureInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input), + )); + } + pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 1835932e8ef2..a14fe7bf2d50 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -285,6 +285,7 @@ fn sample_thread(thread_id: &str) -> Thread { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -313,6 +314,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } @@ -330,6 +332,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }) } @@ -348,6 +351,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eed972852a7a..29d896156b0e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -83,6 +83,9 @@ pub(crate) enum TrackEventRequest { PluginUninstalled(CodexPluginEventRequest), PluginEnabled(CodexPluginEventRequest), PluginDisabled(CodexPluginEventRequest), + PluginInstallFailed(CodexPluginInstallFailedEventRequest), + ExternalAgentConfigImportCompleted(CodexOnboardingExternalAgentImportCompleteEventRequest), + ExternalAgentConfigImportFailure(CodexOnboardingExternalAgentImportFailureEventRequest), } impl TrackEventRequest { @@ -954,6 +957,53 @@ pub(crate) struct CodexPluginEventRequest { pub(crate) event_params: CodexPluginMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedMetadata { + #[serde(flatten)] + pub(crate) plugin: CodexPluginMetadata, + pub(crate) error_type: String, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginInstallFailedMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) success_count: usize, + pub(crate) failed_count: usize, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportCompleteMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) failure_stage: String, + pub(crate) error_type: String, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportFailureMetadata, +} + #[derive(Serialize)] pub(crate) struct CodexPluginUsedEventRequest { pub(crate) event_type: &'static str, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ab278e223cf6..a8d1d6d6b00b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -504,6 +504,9 @@ pub(crate) enum CustomAnalyticsFact { HookRun(HookRunInput), PluginUsed(PluginUsedInput), PluginStateChanged(PluginStateChangedInput), + PluginInstallFailed(PluginInstallFailedInput), + ExternalAgentConfigImportCompleted(ExternalAgentConfigImportCompletedInput), + ExternalAgentConfigImportFailure(ExternalAgentConfigImportFailureInput), } pub(crate) struct SkillInvokedInput { @@ -542,6 +545,27 @@ pub(crate) struct PluginStateChangedInput { pub state: PluginState, } +pub(crate) struct PluginInstallFailedInput { + pub plugin: PluginTelemetryMetadata, + pub error_type: String, +} + +pub struct ExternalAgentConfigImportCompletedInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub success_count: usize, + pub failed_count: usize, +} + +pub struct ExternalAgentConfigImportFailureInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub failure_stage: String, + pub error_type: String, +} + #[derive(Clone, Copy)] pub(crate) enum PluginState { Installed, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 687a3a763127..58228f5c5c6f 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -36,6 +36,8 @@ pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::ExternalAgentConfigImportCompletedInput; +pub use facts::ExternalAgentConfigImportFailureInput; pub use facts::GoalEventKind; pub use facts::HookRunFact; pub use facts::InputError; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 6d40324ec29c..af99b8c1197d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -21,7 +21,13 @@ use crate::events::CodexImageGenerationEventParams; use crate::events::CodexImageGenerationEventRequest; use crate::events::CodexMcpToolCallEventParams; use crate::events::CodexMcpToolCallEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteMetadata; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -66,7 +72,10 @@ use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -526,6 +535,15 @@ impl AnalyticsReducer { CustomAnalyticsFact::PluginStateChanged(input) => { self.ingest_plugin_state_changed(input, out); } + CustomAnalyticsFact::PluginInstallFailed(input) => { + self.ingest_plugin_install_failed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input) => { + self.ingest_external_agent_config_import_completed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input) => { + self.ingest_external_agent_config_import_failure(input, out); + } }, } } @@ -790,6 +808,63 @@ impl AnalyticsReducer { }); } + fn ingest_plugin_install_failed( + &mut self, + input: PluginInstallFailedInput, + out: &mut Vec, + ) { + let PluginInstallFailedInput { plugin, error_type } = input; + out.push(TrackEventRequest::PluginInstallFailed( + CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(plugin), + error_type, + }, + }, + )); + } + + fn ingest_external_agent_config_import_completed( + &mut self, + input: ExternalAgentConfigImportCompletedInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportCompleted( + CodexOnboardingExternalAgentImportCompleteEventRequest { + event_type: "codex_onboarding_external_agent_import_complete", + event_params: CodexOnboardingExternalAgentImportCompleteMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + success_count: input.success_count, + failed_count: input.failed_count, + product_client_id: Some(originator().value), + }, + }, + )); + } + + fn ingest_external_agent_config_import_failure( + &mut self, + input: ExternalAgentConfigImportFailureInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + failure_stage: input.failure_stage, + error_type: input.error_type, + product_client_id: Some(originator().value), + }, + }, + )); + } + async fn ingest_response( &mut self, connection_id: u64, diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bfcf5522d842..b7a958eff1f5 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -350,6 +350,8 @@ pub struct InProcessClientStartArgs { pub client_version: String, /// Whether experimental APIs are requested at initialize time. pub experimental_api: bool, + /// Whether MCP servers may send `openai/form` elicitation requests. + pub mcp_server_openai_form_elicitation: bool, /// Notification methods this client opts out of receiving. pub opt_out_notification_methods: Vec, /// Queue capacity for command/event channels (clamped to at least 1). @@ -374,6 +376,7 @@ impl InProcessClientStartArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, }; InitializeParams { @@ -1044,6 +1047,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity, }) @@ -1237,11 +1241,25 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, } } + #[test] + fn remote_initialize_params_forward_openai_form_capability() { + let mut args = test_remote_connect_args("ws://localhost/rpc".to_string()); + args.mcp_server_openai_form_elicitation = true; + + assert!( + args.initialize_params() + .capabilities + .expect("initialize capabilities") + .mcp_server_openai_form_elicitation + ); + } + #[tokio::test] async fn typed_request_roundtrip_works() { let client = start_test_client(SessionSource::Exec).await; @@ -1512,6 +1530,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1600,6 +1619,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1619,6 +1639,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -2189,7 +2210,7 @@ mod tests { } #[tokio::test] - async fn runtime_start_args_forward_environment_manager() { + async fn runtime_start_args_forward_environment_manager_and_openai_form_capability() { let config = Arc::new(build_test_config().await); let environment_manager = Arc::new( EnvironmentManager::create_for_tests( @@ -2222,12 +2243,20 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } .into_runtime_start_args(); assert_eq!(runtime_args.config, config); + assert!( + runtime_args + .initialize + .capabilities + .expect("initialize capabilities") + .mcp_server_openai_form_elicitation + ); assert!(Arc::ptr_eq( &runtime_args.environment_manager, &environment_manager @@ -2263,6 +2292,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index e1c9f16c4a8d..5575fe4ad46a 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -86,11 +86,12 @@ pub struct RemoteAppServerConnectArgs { pub client_name: String, pub client_version: String, pub experimental_api: bool, + pub mcp_server_openai_form_elicitation: bool, pub opt_out_notification_methods: Vec, pub channel_capacity: usize, } impl RemoteAppServerConnectArgs { - fn initialize_params(&self) -> InitializeParams { + pub(crate) fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, request_attestation: false, @@ -99,6 +100,7 @@ impl RemoteAppServerConnectArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, }; InitializeParams { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8aacd10915e6..066709a3825d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -643,7 +643,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, @@ -800,7 +801,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -813,6 +814,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1264,6 +1272,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -1303,6 +1315,21 @@ ], "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" + }, "ListMcpServerStatusParams": { "properties": { "cursor": { @@ -1746,6 +1773,15 @@ "ModelProviderCapabilitiesReadParams": { "type": "object" }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -2105,13 +2141,6 @@ ], "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -2284,13 +2313,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2337,10 +2365,16 @@ }, "type": "array" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2385,10 +2419,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2433,13 +2473,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2477,13 +2516,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2532,13 +2570,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2572,10 +2609,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2610,16 +2653,15 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2657,10 +2699,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2703,10 +2751,16 @@ "execution": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2753,13 +2807,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2789,12 +2842,15 @@ { "properties": { "id": { - "type": "string" + "type": [ + "string", + "null" + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2822,7 +2878,6 @@ } }, "required": [ - "id", "result", "status", "type" @@ -2835,10 +2890,16 @@ "encrypted_content": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2862,10 +2923,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2894,10 +2955,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2936,17 +3003,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { @@ -4156,7 +4212,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, @@ -4352,7 +4409,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" @@ -6445,6 +6502,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": { @@ -6665,6 +6745,29 @@ "title": "ExternalAgentConfig/importRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/import/readHistories" + ], + "title": "ExternalAgentConfig/import/readHistoriesRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "ExternalAgentConfig/import/readHistoriesRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index c59141103cd4..91f74376fdaa 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -84,9 +84,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "CommandAction": { "oneOf": [ { @@ -289,7 +286,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -472,6 +469,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "NetworkApprovalContext": { "properties": { "host": { @@ -547,7 +547,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" @@ -555,6 +555,14 @@ ], "description": "The command's working directory." }, + "environmentId": { + "default": null, + "description": "Environment in which the command will run.", + "type": [ + "string", + "null" + ] + }, "itemId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json index aa7fa817ae92..3fc697133078 100644 --- a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json @@ -557,6 +557,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 4d7eff3eb558..29b71c8912e3 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -58,9 +58,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "FileSystemAccessMode": { "enum": [ "read", @@ -74,7 +71,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -257,6 +254,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "RequestPermissionProfile": { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index e0bfd161f21a..909cb5f67021 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -23,7 +23,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -33,7 +33,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -54,9 +54,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "FileSystemAccessMode": { "enum": [ "read", @@ -70,7 +67,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -278,6 +275,9 @@ }, "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "PermissionGrantScope": { "enum": [ "turn", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 771d8268bf45..ab270ce7dee5 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -107,7 +107,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -117,7 +117,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -164,9 +164,6 @@ "AgentPath": { "type": "string" }, - "ApiPathString": { - "type": "string" - }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -1167,6 +1164,12 @@ "null" ] }, + "errorType": { + "type": [ + "string", + "null" + ] + }, "failureStage": { "type": "string" }, @@ -1219,6 +1222,24 @@ ], "type": "object" }, + "ExternalAgentConfigImportProgressNotification": { + "properties": { + "importId": { + "type": "string" + }, + "itemTypeResults": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportTypeResult" + }, + "type": "array" + } + }, + "required": [ + "importId", + "itemTypeResults" + ], + "type": "object" + }, "ExternalAgentConfigImportTypeResult": { "properties": { "failures": { @@ -1321,7 +1342,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -2315,6 +2336,9 @@ ], "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "McpServerOauthLoginCompletedNotification": { "properties": { "error": { @@ -2372,6 +2396,29 @@ ], "type": "object" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -2538,6 +2585,39 @@ ], "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "type": "object" + }, "ModelVerification": { "enum": [ "trustedAccessForCyber" @@ -2566,6 +2646,15 @@ ], "type": "object" }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -3560,6 +3649,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -3942,7 +4039,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -4036,6 +4133,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -4059,6 +4166,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" @@ -6335,6 +6443,26 @@ "title": "RemoteControl/status/changedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "externalAgentConfig/import/progress" + ], + "title": "ExternalAgentConfig/import/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportProgressNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ExternalAgentConfig/import/progressNotification", + "type": "object" + }, { "properties": { "method": { @@ -6516,6 +6644,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/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 34ccaa47e9c7..c6ac80dee514 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -84,9 +84,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "ApplyPatchApprovalParams": { "properties": { "callId": { @@ -374,7 +371,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" @@ -382,6 +379,14 @@ ], "description": "The command's working directory." }, + "environmentId": { + "default": null, + "description": "Environment in which the command will run.", + "type": [ + "string", + "null" + ] + }, "itemId": { "type": "string" }, @@ -643,7 +648,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -826,6 +831,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "McpElicitationArrayType": { "enum": [ "array" @@ -1382,6 +1390,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, 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 ea84ddb6c45a..d0c842494424 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 @@ -1928,6 +1928,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": { @@ -2148,6 +2171,29 @@ "title": "ExternalAgentConfig/importRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/import/readHistories" + ], + "title": "ExternalAgentConfig/import/readHistoriesRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "ExternalAgentConfig/import/readHistoriesRequest", + "type": "object" + }, { "properties": { "id": { @@ -2379,7 +2425,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, { "type": "null" @@ -2387,6 +2433,14 @@ ], "description": "The command's working directory." }, + "environmentId": { + "default": null, + "description": "Environment in which the command will run.", + "type": [ + "string", + "null" + ] + }, "itemId": { "type": "string" }, @@ -2893,6 +2947,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -3648,6 +3706,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, @@ -4858,6 +4937,26 @@ "title": "RemoteControl/status/changedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "externalAgentConfig/import/progress" + ], + "title": "ExternalAgentConfig/import/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExternalAgentConfigImportProgressNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ExternalAgentConfig/import/progressNotification", + "type": "object" + }, { "properties": { "method": { @@ -5039,6 +5138,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": { @@ -5805,7 +5924,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/v2/PlanType" @@ -5861,6 +5983,14 @@ }, { "properties": { + "credentialSource": { + "allOf": [ + { + "$ref": "#/definitions/v2/AmazonBedrockCredentialSource" + } + ], + "default": "awsManaged" + }, "type": { "enum": [ "amazonBedrock" @@ -6136,7 +6266,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/v2/ApiPathString" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": [ "array", @@ -6146,7 +6276,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/v2/ApiPathString" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": [ "array", @@ -6239,6 +6369,13 @@ "AgentPath": { "type": "string" }, + "AmazonBedrockCredentialSource": { + "enum": [ + "codexManaged", + "awsManaged" + ], + "type": "string" + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -6251,9 +6388,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -6757,6 +6891,16 @@ } ] }, + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolApproval" + }, + { + "type": "null" + } + ] + }, "destructive_enabled": { "default": true, "type": "boolean" @@ -8750,7 +8894,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, @@ -9162,7 +9307,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -9205,6 +9350,52 @@ "title": "ExternalAgentConfigImportCompletedNotification", "type": "object" }, + "ExternalAgentConfigImportHistoriesReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/ExternalAgentConfigImportHistory" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "ExternalAgentConfigImportHistoriesReadResponse", + "type": "object" + }, + "ExternalAgentConfigImportHistory": { + "properties": { + "completedAtMs": { + "format": "int64", + "type": "integer" + }, + "failures": { + "items": { + "$ref": "#/definitions/v2/ExternalAgentConfigImportItemTypeFailure" + }, + "type": "array" + }, + "importId": { + "type": "string" + }, + "successes": { + "items": { + "$ref": "#/definitions/v2/ExternalAgentConfigImportItemTypeSuccess" + }, + "type": "array" + } + }, + "required": [ + "completedAtMs", + "failures", + "importId", + "successes" + ], + "type": "object" + }, "ExternalAgentConfigImportItemTypeFailure": { "properties": { "cwd": { @@ -9213,6 +9404,12 @@ "null" ] }, + "errorType": { + "type": [ + "string", + "null" + ] + }, "failureStage": { "type": "string" }, @@ -9273,6 +9470,13 @@ "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -9281,6 +9485,26 @@ "title": "ExternalAgentConfigImportParams", "type": "object" }, + "ExternalAgentConfigImportProgressNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "importId": { + "type": "string" + }, + "itemTypeResults": { + "items": { + "$ref": "#/definitions/v2/ExternalAgentConfigImportTypeResult" + }, + "type": "array" + } + }, + "required": [ + "importId", + "itemTypeResults" + ], + "title": "ExternalAgentConfigImportProgressNotification", + "type": "object" + }, "ExternalAgentConfigImportResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9491,7 +9715,7 @@ { "properties": { "path": { - "$ref": "#/definitions/v2/ApiPathString" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": { "enum": [ @@ -10272,6 +10496,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": { @@ -11018,6 +11264,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": { @@ -11170,6 +11428,9 @@ "title": "ItemStartedNotification", "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "ListMcpServerStatusParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12054,6 +12315,29 @@ "title": "McpServerToolCallResponse", "type": "object" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -12473,6 +12757,41 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { @@ -12551,6 +12870,15 @@ "title": "ModelVerificationNotification", "type": "object" }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -12835,6 +13163,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": [ @@ -12848,6 +13180,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" @@ -14244,13 +14577,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -14758,13 +15084,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -14811,10 +15136,16 @@ }, "type": "array" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -14859,10 +15190,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -14907,13 +15244,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -14951,13 +15287,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15006,13 +15341,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15046,10 +15380,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15084,16 +15424,15 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15131,10 +15470,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15177,10 +15522,16 @@ "execution": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15227,13 +15578,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15263,12 +15613,15 @@ { "properties": { "id": { - "type": "string" + "type": [ + "string", + "null" + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15296,7 +15649,6 @@ } }, "required": [ - "id", "result", "status", "type" @@ -15309,10 +15661,16 @@ "encrypted_content": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15336,10 +15694,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15368,10 +15726,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15410,17 +15774,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { @@ -16609,6 +16962,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -16913,9 +17274,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, @@ -17380,7 +17741,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" } ], "description": "The command's working directory." @@ -17474,6 +17835,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -17497,6 +17868,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" @@ -18617,9 +18989,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, @@ -18868,7 +19240,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, @@ -19033,9 +19406,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, @@ -19532,7 +19905,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "environmentId": { "type": "string" @@ -20231,6 +20604,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" @@ -20383,6 +20757,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 735eb57d1ba3..88f3306b0323 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" @@ -82,6 +85,14 @@ }, { "properties": { + "credentialSource": { + "allOf": [ + { + "$ref": "#/definitions/AmazonBedrockCredentialSource" + } + ], + "default": "awsManaged" + }, "type": { "enum": [ "amazonBedrock" @@ -357,7 +368,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -367,7 +378,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -460,6 +471,13 @@ "AgentPath": { "type": "string" }, + "AmazonBedrockCredentialSource": { + "enum": [ + "codexManaged", + "awsManaged" + ], + "type": "string" + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -472,9 +490,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -978,6 +993,16 @@ } ] }, + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, "destructive_enabled": { "default": true, "type": "boolean" @@ -3007,6 +3032,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": { @@ -3227,6 +3275,29 @@ "title": "ExternalAgentConfig/importRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/import/readHistories" + ], + "title": "ExternalAgentConfig/import/readHistoriesRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "ExternalAgentConfig/import/readHistoriesRequest", + "type": "object" + }, { "properties": { "id": { @@ -5063,7 +5134,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, @@ -5475,7 +5547,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -5518,6 +5590,52 @@ "title": "ExternalAgentConfigImportCompletedNotification", "type": "object" }, + "ExternalAgentConfigImportHistoriesReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportHistory" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "ExternalAgentConfigImportHistoriesReadResponse", + "type": "object" + }, + "ExternalAgentConfigImportHistory": { + "properties": { + "completedAtMs": { + "format": "int64", + "type": "integer" + }, + "failures": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeFailure" + }, + "type": "array" + }, + "importId": { + "type": "string" + }, + "successes": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeSuccess" + }, + "type": "array" + } + }, + "required": [ + "completedAtMs", + "failures", + "importId", + "successes" + ], + "type": "object" + }, "ExternalAgentConfigImportItemTypeFailure": { "properties": { "cwd": { @@ -5526,6 +5644,12 @@ "null" ] }, + "errorType": { + "type": [ + "string", + "null" + ] + }, "failureStage": { "type": "string" }, @@ -5586,6 +5710,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -5594,6 +5725,26 @@ "title": "ExternalAgentConfigImportParams", "type": "object" }, + "ExternalAgentConfigImportProgressNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "importId": { + "type": "string" + }, + "itemTypeResults": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportTypeResult" + }, + "type": "array" + } + }, + "required": [ + "importId", + "itemTypeResults" + ], + "title": "ExternalAgentConfigImportProgressNotification", + "type": "object" + }, "ExternalAgentConfigImportResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5804,7 +5955,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -6696,6 +6847,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": { @@ -7431,6 +7604,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -7491,6 +7668,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": { @@ -7643,6 +7832,9 @@ "title": "ItemStartedNotification", "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "ListMcpServerStatusParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8527,6 +8719,29 @@ "title": "McpServerToolCallResponse", "type": "object" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -8946,6 +9161,41 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { @@ -9024,6 +9274,15 @@ "title": "ModelVerificationNotification", "type": "object" }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -9308,6 +9567,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": [ @@ -9321,6 +9584,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" @@ -10717,13 +10981,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -11231,13 +11488,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11284,10 +11540,16 @@ }, "type": "array" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11332,10 +11594,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11380,13 +11648,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11424,13 +11691,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11479,13 +11745,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11519,10 +11784,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11557,16 +11828,15 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11604,10 +11874,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11650,10 +11926,16 @@ "execution": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11700,13 +11982,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11736,12 +12017,15 @@ { "properties": { "id": { - "type": "string" + "type": [ + "string", + "null" + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11769,7 +12053,6 @@ } }, "required": [ - "id", "result", "status", "type" @@ -11782,10 +12065,16 @@ "encrypted_content": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11809,10 +12098,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11841,10 +12130,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11883,17 +12178,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { @@ -13129,6 +13413,26 @@ "title": "RemoteControl/status/changedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "externalAgentConfig/import/progress" + ], + "title": "ExternalAgentConfig/import/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportProgressNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ExternalAgentConfig/import/progressNotification", + "type": "object" + }, { "properties": { "method": { @@ -13310,6 +13614,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": { @@ -14417,6 +14741,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -14721,9 +15053,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, @@ -15188,7 +15520,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -15282,6 +15614,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -15305,6 +15647,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" @@ -16425,9 +16768,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, @@ -16676,7 +17019,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, @@ -16841,9 +17185,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, @@ -17340,7 +17684,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" @@ -18039,6 +18383,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" @@ -18191,6 +18536,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/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index af5c509249a2..75f0860ddda5 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -30,6 +30,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 3bc0a8e0a9a0..ef2411534ddf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -143,6 +143,16 @@ } ] }, + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, "destructive_enabled": { "default": true, "type": "boolean" @@ -799,6 +809,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index e57bd48b796f..edbb7f0bcda5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -503,6 +503,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 20ddd6e48aaa..01fda3b07139 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -12,7 +12,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportCompletedNotification.json index 2b65371c6eb8..51d0e95937d4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportCompletedNotification.json @@ -9,6 +9,12 @@ "null" ] }, + "errorType": { + "type": [ + "string", + "null" + ] + }, "failureStage": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportHistoriesReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportHistoriesReadResponse.json new file mode 100644 index 000000000000..68921f6b24fe --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportHistoriesReadResponse.json @@ -0,0 +1,128 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ExternalAgentConfigImportHistory": { + "properties": { + "completedAtMs": { + "format": "int64", + "type": "integer" + }, + "failures": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeFailure" + }, + "type": "array" + }, + "importId": { + "type": "string" + }, + "successes": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeSuccess" + }, + "type": "array" + } + }, + "required": [ + "completedAtMs", + "failures", + "importId", + "successes" + ], + "type": "object" + }, + "ExternalAgentConfigImportItemTypeFailure": { + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "errorType": { + "type": [ + "string", + "null" + ] + }, + "failureStage": { + "type": "string" + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "message": { + "type": "string" + }, + "source": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "failureStage", + "itemType", + "message" + ], + "type": "object" + }, + "ExternalAgentConfigImportItemTypeSuccess": { + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "itemType" + ], + "type": "object" + }, + "ExternalAgentConfigMigrationItemType": { + "enum": [ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS" + ], + "type": "string" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportHistory" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "ExternalAgentConfigImportHistoriesReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index b26e9d187aae..bc432b873afa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -184,6 +184,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportProgressNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportProgressNotification.json new file mode 100644 index 000000000000..32975b2c2daa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportProgressNotification.json @@ -0,0 +1,127 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ExternalAgentConfigImportItemTypeFailure": { + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "errorType": { + "type": [ + "string", + "null" + ] + }, + "failureStage": { + "type": "string" + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "message": { + "type": "string" + }, + "source": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "failureStage", + "itemType", + "message" + ], + "type": "object" + }, + "ExternalAgentConfigImportItemTypeSuccess": { + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "itemType" + ], + "type": "object" + }, + "ExternalAgentConfigImportTypeResult": { + "properties": { + "failures": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeFailure" + }, + "type": "array" + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "successes": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeSuccess" + }, + "type": "array" + } + }, + "required": [ + "failures", + "itemType", + "successes" + ], + "type": "object" + }, + "ExternalAgentConfigMigrationItemType": { + "enum": [ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS" + ], + "type": "string" + } + }, + "properties": { + "importId": { + "type": "string" + }, + "itemTypeResults": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportTypeResult" + }, + "type": "array" + } + }, + "required": [ + "importId", + "itemTypeResults" + ], + "title": "ExternalAgentConfigImportProgressNotification", + "type": "object" +} \ No newline at end of file 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 ebf00dc964fe..544db33d9ec3 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" @@ -78,6 +81,14 @@ }, { "properties": { + "credentialSource": { + "allOf": [ + { + "$ref": "#/definitions/AmazonBedrockCredentialSource" + } + ], + "default": "awsManaged" + }, "type": { "enum": [ "amazonBedrock" @@ -153,6 +164,13 @@ ], "type": "object" }, + "AmazonBedrockCredentialSource": { + "enum": [ + "codexManaged", + "awsManaged" + ], + "type": "string" + }, "PlanType": { "enum": [ "free", 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 000000000000..4d1246a1b217 --- /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/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 233ff8dc98e2..d7bfb3827e3f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -294,6 +294,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -687,7 +713,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -781,6 +807,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -804,6 +840,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index 717c4c987aa8..48a3193cc735 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -58,9 +58,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "AutoReviewDecisionSource": { "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "enum": [ @@ -81,7 +78,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -536,6 +533,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index 28fec1275bb4..9f534e517d2c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -27,7 +27,7 @@ "read": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -37,7 +37,7 @@ "write": { "description": "This will be removed in favor of `entries`.", "items": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": [ "array", @@ -58,9 +58,6 @@ }, "type": "object" }, - "ApiPathString": { - "type": "string" - }, "FileSystemAccessMode": { "enum": [ "read", @@ -74,7 +71,7 @@ { "properties": { "path": { - "$ref": "#/definitions/ApiPathString" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -529,6 +526,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index a55d9e776649..770fc8bd4c6d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -294,6 +294,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -687,7 +713,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -781,6 +807,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -804,6 +840,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" 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 000000000000..5ed692480b7a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "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 4d5a47f8d5d6..1027b9c5a0d3 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/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index fdd40dae6106..d06d821a7d63 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": [ { @@ -381,13 +393,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -434,10 +445,16 @@ }, "type": "array" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -482,10 +499,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -530,13 +553,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -574,13 +596,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -629,13 +650,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -669,10 +689,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -707,16 +733,15 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -754,10 +779,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -800,10 +831,16 @@ "execution": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -850,13 +887,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -886,12 +922,15 @@ { "properties": { "id": { - "type": "string" + "type": [ + "string", + "null" + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -919,7 +958,6 @@ } }, "required": [ - "id", "result", "status", "type" @@ -932,10 +970,16 @@ "encrypted_content": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -959,10 +1003,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -991,10 +1035,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1033,17 +1083,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e27c911b3600..545ae64d4af5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -431,6 +431,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +857,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -925,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -948,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" 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 4a666672b66e..c19251667a38 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -536,6 +536,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -636,6 +662,15 @@ } ] }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -1055,6 +1090,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1315,7 +1358,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1409,6 +1452,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1432,6 +1485,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" @@ -2323,9 +2377,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 789d9b61fcd5..11f7da47d380 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -24,7 +24,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index c1d1b6759a80..9925c5411e1f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -457,6 +457,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -870,6 +896,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1130,7 +1164,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1224,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1247,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 801d1cf131be..1e142947ed1a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -457,6 +457,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -870,6 +896,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1130,7 +1164,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1224,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1247,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 01b5ed907efc..532a712807f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -457,6 +457,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -870,6 +896,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1130,7 +1164,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1224,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1247,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" 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 7ba2a0a64696..4ce88a116273 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": [ { @@ -452,13 +464,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -505,10 +516,16 @@ }, "type": "array" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -553,10 +570,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -601,13 +624,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -645,13 +667,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -700,13 +721,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -740,10 +760,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -778,16 +804,15 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -825,10 +850,16 @@ "call_id": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -871,10 +902,16 @@ "execution": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -921,13 +958,12 @@ "type": [ "string", "null" - ], - "writeOnly": true + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -957,12 +993,15 @@ { "properties": { "id": { - "type": "string" + "type": [ + "string", + "null" + ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -990,7 +1029,6 @@ } }, "required": [ - "id", "result", "status", "type" @@ -1003,10 +1041,16 @@ "encrypted_content": { "type": "string" }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1030,10 +1074,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1062,10 +1106,16 @@ "null" ] }, - "metadata": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1104,17 +1154,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "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 032d1dd74ef2..1a3b22f12cbf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -536,6 +536,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -636,6 +662,15 @@ } ] }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -1055,6 +1090,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1315,7 +1358,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1409,6 +1452,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1432,6 +1485,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" @@ -2349,9 +2403,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index c1ad534756fd..9f394b31cf11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -457,6 +457,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -870,6 +896,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1130,7 +1164,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1224,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1247,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" 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 73f261e65d11..f1ce05eaffa4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -109,6 +109,15 @@ ], "type": "string" }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", 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 59e05c773b88..9932456f1b55 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -191,6 +191,18 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "Personality": { "enum": [ "none", @@ -242,7 +254,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" 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 33373b9f7208..a754c6bc0f3c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -536,6 +536,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -636,6 +662,15 @@ } ] }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -1055,6 +1090,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1315,7 +1358,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1409,6 +1452,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1432,6 +1485,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" @@ -2323,9 +2377,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 577df57e0501..22801809cd72 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -457,6 +457,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -870,6 +896,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1130,7 +1164,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1224,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1247,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index fcc42e6261c0..3009e86e70bd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -457,6 +457,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -870,6 +896,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -1130,7 +1164,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -1224,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1247,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 24d1dccf475c..b9a940157ce4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -431,6 +431,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +857,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -925,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -948,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" 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 56b98805b341..f8276c38e3f7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -130,6 +130,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -139,6 +142,15 @@ ], "type": "string" }, + "MultiAgentMode": { + "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" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -332,7 +344,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index da88eacd4874..da10713dd18d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -431,6 +431,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +857,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -925,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -948,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index edb3fa636a07..6b9349822137 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -431,6 +431,32 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +857,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." @@ -925,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -948,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationArchitecture.ts b/codex-rs/app-server-protocol/schema/typescript/AmazonBedrockCredentialSource.ts similarity index 67% rename from codex-rs/app-server-protocol/schema/typescript/RealtimeConversationArchitecture.ts rename to codex-rs/app-server-protocol/schema/typescript/AmazonBedrockCredentialSource.ts index 4467e4a0f7cd..7822a2f8b427 100644 --- a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationArchitecture.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AmazonBedrockCredentialSource.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 RealtimeConversationArchitecture = "realtimeapi" | "avas"; +export type AmazonBedrockCredentialSource = "codexManaged" | "awsManaged"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 9c6a7df59cf4..44eacdc22b76 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -89,4 +89,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/workflowCommand", id: RequestId, params: ThreadWorkflowCommandParams, } | { "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": "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/workflowCommand", id: RequestId, params: ThreadWorkflowCommandParams, } | { "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/ConversationTextRole.ts b/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts index 9cba89f83701..a4d574b4db4c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.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 ConversationTextRole = "user" | "developer"; +export type ConversationTextRole = "user" | "developer" | "assistant"; diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index c5043e3b64fc..dcc4dffb0b51 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -14,6 +14,10 @@ experimentalApi: boolean, * Opt into `attestation/generate` requests for upstream `x-oai-attestation`. */ requestAttestation: boolean, +/** + * Allow downstream MCP servers to request OpenAI extended form elicitations. + */ +mcpServerOpenaiFormElicitation?: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). 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 000000000000..6ccf3868847d --- /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/ApiPathString.ts b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts similarity index 96% rename from codex-rs/app-server-protocol/schema/typescript/ApiPathString.ts rename to codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts index ba5a3a961c04..04e465b8a29c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ApiPathString.ts +++ b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts @@ -23,4 +23,4 @@ * [`AbsolutePathBuf`]. Relative path text remains valid until an operation * such as [`Self::to_path_uri`] requires an absolute path. */ -export type ApiPathString = string; +export type LegacyAppPathString = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts new file mode 100644 index 000000000000..b59be94f8ecd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.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. + +/** + * 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 = "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 5bd9a1032bae..074e11a16e00 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -4,16 +4,20 @@ 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", role: string, content: Array, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", author: string, recipient: string, content: Array, metadata?: ResponseItemMetadata, } | { "type": "reasoning", 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. + */ +id?: string, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", 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", internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "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 7cae63a55338..f3f828b8ac17 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -15,6 +15,7 @@ import type { ContextCompactedNotification } from "./v2/ContextCompactedNotifica import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification"; import type { ErrorNotification } from "./v2/ErrorNotification"; import type { ExternalAgentConfigImportCompletedNotification } from "./v2/ExternalAgentConfigImportCompletedNotification"; +import type { ExternalAgentConfigImportProgressNotification } from "./v2/ExternalAgentConfigImportProgressNotification"; import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification"; import type { FileChangePatchUpdatedNotification } from "./v2/FileChangePatchUpdatedNotification"; import type { FsChangedNotification } from "./v2/FsChangedNotification"; @@ -29,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"; @@ -72,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/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/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 80e9ffc1162c..89a544005648 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -16,4 +16,4 @@ import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams /** * Request initiated from the server and sent to the client. */ -export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; +export type ServerRequest ={ "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts index 695c13e3f6f1..0544fd09c83b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.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 WebSearchMode = "disabled" | "cached" | "live"; +export type WebSearchMode = "disabled" | "cached" | "indexed" | "live"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 66fea07dca97..dcfecf12823c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -3,7 +3,7 @@ export type { AbsolutePathBuf } from "./AbsolutePathBuf"; export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentPath } from "./AgentPath"; -export type { ApiPathString } from "./ApiPathString"; +export type { AmazonBedrockCredentialSource } from "./AmazonBedrockCredentialSource"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; export type { AuthMode } from "./AuthMode"; @@ -41,19 +41,21 @@ 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"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; +export type { MultiAgentMode } from "./MultiAgentMode"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; export type { Personality } from "./Personality"; export type { PlanType } from "./PlanType"; -export type { RealtimeConversationArchitecture } from "./RealtimeConversationArchitecture"; export type { RealtimeConversationVersion } from "./RealtimeConversationVersion"; export type { RealtimeOutputModality } from "./RealtimeOutputModality"; export type { RealtimeVoice } from "./RealtimeVoice"; @@ -67,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 22e1967ddc80..dc752bc72a51 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -1,7 +1,8 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource"; import type { PlanType } from "../PlanType"; import type { AccountPoolMember } from "./AccountPoolMember"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "chatgptPool", id: string, activeAccountId: string | null, members: Array, } | { "type": "amazonBedrock", }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "chatgptPool", id: string, activeAccountId: string | null, members: Array, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts index 68b1fb660360..f4ca94efd3be 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts @@ -1,15 +1,15 @@ // 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 { ApiPathString } from "../ApiPathString"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; export type AdditionalFileSystemPermissions = { /** * This will be removed in favor of `entries`. */ -read: Array | null, +read: Array | null, /** * This will be removed in favor of `entries`. */ -write: Array | null, globScanMaxDepth?: number, entries?: Array, }; +write: Array | null, globScanMaxDepth?: number, entries?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts index 8277320e6989..6b841ef3567d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppToolApproval } from "./AppToolApproval"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; -export type AppsDefaultConfig = { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean, open_world_enabled: boolean, }; +export type AppsDefaultConfig = { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean, open_world_enabled: boolean, default_tools_approval_mode: AppToolApproval | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 0e9100836a61..4f02c92cae25 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -1,7 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { CommandAction } from "./CommandAction"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; @@ -20,6 +20,9 @@ startedAtMs: number, /** * (a UUID) used to disambiguate routing. */ approvalId?: string | null, /** + * Environment in which the command will run. + */ +environmentId: string | null, /** * Optional explanatory reason (e.g. request for network access). */ reason?: string | null, /** @@ -31,7 +34,7 @@ networkApprovalContext?: NetworkApprovalContext | null, /** command?: string | null, /** * The command's working directory. */ -cwd?: AbsolutePathBuf | null, /** +cwd?: LegacyAppPathString | null, /** * Best-effort parsed command actions for friendly display. */ commandActions?: Array | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts index 163d96192536..48b55e9b9ac3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -4,7 +4,7 @@ export type ExternalAgentConfigDetectParams = { /** - * If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + * If true, include detection under the user's home directory. */ includeHome?: boolean, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistoriesReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistoriesReadResponse.ts new file mode 100644 index 000000000000..9df61f556d0e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistoriesReadResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExternalAgentConfigImportHistory } from "./ExternalAgentConfigImportHistory"; + +export type ExternalAgentConfigImportHistoriesReadResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistory.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistory.ts new file mode 100644 index 000000000000..37273fc96c3c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistory.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExternalAgentConfigImportItemTypeFailure } from "./ExternalAgentConfigImportItemTypeFailure"; +import type { ExternalAgentConfigImportItemTypeSuccess } from "./ExternalAgentConfigImportItemTypeSuccess"; + +export type ExternalAgentConfigImportHistory = { importId: string, completedAtMs: bigint, successes: Array, failures: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportItemTypeFailure.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportItemTypeFailure.ts index 97386cba70d3..13e02bf17966 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportItemTypeFailure.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportItemTypeFailure.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; -export type ExternalAgentConfigImportItemTypeFailure = { itemType: ExternalAgentConfigMigrationItemType, failureStage: string, message: string, cwd: string | null, source: string | null, }; +export type ExternalAgentConfigImportItemTypeFailure = { itemType: ExternalAgentConfigMigrationItemType, errorType: string | null, failureStage: string, message: string, cwd: string | null, source: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts index 7bc5d9d91f49..be7f7ffe3018 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; -export type ExternalAgentConfigImportParams = { migrationItems: Array, }; +export type ExternalAgentConfigImportParams = { migrationItems: Array, +/** + * Source product that produced the migration items. Missing means unspecified. + */ +source?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportProgressNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportProgressNotification.ts new file mode 100644 index 000000000000..2115d633d1c7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportProgressNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExternalAgentConfigImportTypeResult } from "./ExternalAgentConfigImportTypeResult"; + +export type ExternalAgentConfigImportProgressNotification = { importId: string, itemTypeResults: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts index 0733c02040dd..cf391512bb34 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts @@ -1,7 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ApiPathString } from "../ApiPathString"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; -export type FileSystemPath = { "type": "path", path: ApiPathString, } | { "type": "glob_pattern", pattern: string, } | { "type": "special", value: FileSystemSpecialPath, }; +export type FileSystemPath = { "type": "path", path: LegacyAppPathString, } | { "type": "glob_pattern", pattern: string, } | { "type": "special", value: FileSystemSpecialPath, }; 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 000000000000..949ad433a1f3 --- /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/McpServerElicitationRequestParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts index 90d60f77c7b7..a4f1e732c64c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts @@ -13,4 +13,4 @@ export type McpServerElicitationRequestParams = { threadId: string, * context is app-server correlation rather than part of the protocol identity of the * elicitation itself. */ -turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); +turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts new file mode 100644 index 000000000000..0a8343428dc9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.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 McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, }; 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 000000000000..484f8c6146bf --- /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, }; 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 9d02fd776b4e..5796d30eed87 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/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5fa30b64e32b..1c288ccbd4d1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -41,6 +41,10 @@ createdAt: number, * Unix timestamp (in seconds) when the thread was last updated. */ updatedAt: number, +/** + * Unix timestamp (in seconds) used for thread recency ordering. + */ +recencyAt: number | null, /** * Current runtime status for the thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c5b1201c2651..957756247603 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 4ccab77b32ad..eb8c6486b88b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -15,6 +16,7 @@ import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputCo import type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; import type { FileUpdateChange } from "./FileUpdateChange"; import type { HookPromptFragment } from "./HookPromptFragment"; +import type { McpToolCallAppContext } from "./McpToolCallAppContext"; import type { McpToolCallError } from "./McpToolCallError"; import type { McpToolCallResult } from "./McpToolCallResult"; import type { McpToolCallStatus } from "./McpToolCallStatus"; @@ -32,7 +34,7 @@ command: string, /** * The command's working directory. */ -cwd: AbsolutePathBuf, +cwd: LegacyAppPathString, /** * Identifier for the underlying PTY process (when available). */ @@ -54,7 +56,11 @@ exitCode: number | null, /** * The duration of the command execution in milliseconds. */ -durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null, +durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, appContext: McpToolCallAppContext | null, +/** + * Deprecated: use `appContext.resourceUri` instead. + */ +mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null, /** * The duration of the MCP tool call in milliseconds. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 7a4f90377c6a..e1f7d642be04 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts index bcfd0ad86ce3..b034ea80bb35 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -11,4 +11,4 @@ import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; +export type ThreadSettings = {cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts index dbf1b6c40fd0..d93f1c47bfe9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.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 ThreadSortKey = "created_at" | "updated_at"; +export type ThreadSortKey = "created_at" | "updated_at" | "recency_at"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 38859a3805d8..992ab5dba7d0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts index bb981b0ac973..cb93ba396411 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -1,6 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; -export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; +export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, }; 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 000000000000..b024ce11631c --- /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 66% rename from codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts index f7b69a6dfe17..9d9438d9646d 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, }; +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 a6711852c191..8be48ce35395 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -111,9 +111,12 @@ export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; export type { ExternalAgentConfigDetectParams } from "./ExternalAgentConfigDetectParams"; export type { ExternalAgentConfigDetectResponse } from "./ExternalAgentConfigDetectResponse"; export type { ExternalAgentConfigImportCompletedNotification } from "./ExternalAgentConfigImportCompletedNotification"; +export type { ExternalAgentConfigImportHistoriesReadResponse } from "./ExternalAgentConfigImportHistoriesReadResponse"; +export type { ExternalAgentConfigImportHistory } from "./ExternalAgentConfigImportHistory"; export type { ExternalAgentConfigImportItemTypeFailure } from "./ExternalAgentConfigImportItemTypeFailure"; export type { ExternalAgentConfigImportItemTypeSuccess } from "./ExternalAgentConfigImportItemTypeSuccess"; export type { ExternalAgentConfigImportParams } from "./ExternalAgentConfigImportParams"; +export type { ExternalAgentConfigImportProgressNotification } from "./ExternalAgentConfigImportProgressNotification"; export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImportResponse"; export type { ExternalAgentConfigImportTypeResult } from "./ExternalAgentConfigImportTypeResult"; export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; @@ -155,6 +158,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"; @@ -241,6 +245,7 @@ export type { McpServerStatusDetail } from "./McpServerStatusDetail"; export type { McpServerStatusUpdatedNotification } from "./McpServerStatusUpdatedNotification"; export type { McpServerToolCallParams } from "./McpServerToolCallParams"; export type { McpServerToolCallResponse } from "./McpServerToolCallResponse"; +export type { McpToolCallAppContext } from "./McpToolCallAppContext"; export type { McpToolCallError } from "./McpToolCallError"; export type { McpToolCallProgressNotification } from "./McpToolCallProgressNotification"; export type { McpToolCallResult } from "./McpToolCallResult"; @@ -257,6 +262,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"; @@ -492,4 +498,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/export.rs b/codex-rs/app-server-protocol/src/export.rs index 5e6c2ad015af..91eca690f114 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -14,6 +14,9 @@ use crate::export_server_responses; use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES; use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES; use crate::protocol::common::EXPERIMENTAL_CLIENT_METHODS; +use crate::protocol::common::EXPERIMENTAL_SERVER_METHOD_PARAM_TYPES; +use crate::protocol::common::EXPERIMENTAL_SERVER_METHOD_RESPONSE_TYPES; +use crate::protocol::common::EXPERIMENTAL_SERVER_METHODS; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; @@ -249,10 +252,10 @@ fn filter_experimental_ts(out_dir: &Path) -> Result<()> { let registered_fields = experimental_fields(); let experimental_method_types = experimental_method_types(); // Most generated TS files are filtered by schema processing, but - // `ClientRequest.ts` and any type with `#[experimental(...)]` fields need - // direct post-processing because they encode method/field information in - // file-local unions/interfaces. - filter_client_request_ts(out_dir, EXPERIMENTAL_CLIENT_METHODS)?; + // Request unions and types with `#[experimental(...)]` fields need direct + // post-processing because they encode method/field information locally. + filter_request_ts(out_dir, "ClientRequest.ts", EXPERIMENTAL_CLIENT_METHODS)?; + filter_request_ts(out_dir, "ServerRequest.ts", EXPERIMENTAL_SERVER_METHODS)?; filter_experimental_type_fields_ts(out_dir, ®istered_fields)?; remove_generated_type_files(out_dir, &experimental_method_types, "ts")?; Ok(()) @@ -261,10 +264,13 @@ fn filter_experimental_ts(out_dir: &Path) -> Result<()> { pub(crate) fn filter_experimental_ts_tree(tree: &mut BTreeMap) -> Result<()> { let registered_fields = experimental_fields(); let experimental_method_types = experimental_method_types(); - if let Some(content) = tree.get_mut(Path::new("ClientRequest.ts")) { - let filtered = - filter_client_request_ts_contents(std::mem::take(content), EXPERIMENTAL_CLIENT_METHODS); - *content = filtered; + for (file_name, experimental_methods) in [ + ("ClientRequest.ts", EXPERIMENTAL_CLIENT_METHODS), + ("ServerRequest.ts", EXPERIMENTAL_SERVER_METHODS), + ] { + if let Some(content) = tree.get_mut(Path::new(file_name)) { + *content = filter_request_ts_contents(std::mem::take(content), experimental_methods); + } } let mut fields_by_type_name: HashMap> = HashMap::new(); @@ -293,21 +299,21 @@ pub(crate) fn filter_experimental_ts_tree(tree: &mut BTreeMap) Ok(()) } -/// Removes union arms from `ClientRequest.ts` for methods marked experimental. -fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Result<()> { - let path = out_dir.join("ClientRequest.ts"); +/// Removes union arms from a generated request type for methods marked experimental. +fn filter_request_ts(out_dir: &Path, file_name: &str, experimental_methods: &[&str]) -> Result<()> { + let path = out_dir.join(file_name); if !path.exists() { return Ok(()); } let mut content = fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?; - content = filter_client_request_ts_contents(content, experimental_methods); + content = filter_request_ts_contents(content, experimental_methods); fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; Ok(()) } -fn filter_client_request_ts_contents(mut content: String, experimental_methods: &[&str]) -> String { +fn filter_request_ts_contents(mut content: String, experimental_methods: &[&str]) -> String { let Some((prefix, body, suffix)) = split_type_alias(&content) else { return content; }; @@ -404,6 +410,7 @@ fn filter_experimental_schema(bundle: &mut Value) -> Result<()> { filter_experimental_fields_in_root(bundle, ®istered_fields); filter_experimental_fields_in_definitions(bundle, ®istered_fields); prune_experimental_methods(bundle, EXPERIMENTAL_CLIENT_METHODS); + prune_experimental_methods(bundle, EXPERIMENTAL_SERVER_METHODS); remove_experimental_method_type_definitions(bundle); Ok(()) } @@ -560,6 +567,8 @@ fn experimental_method_types() -> HashSet { collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES, &mut type_names); + collect_experimental_type_names(EXPERIMENTAL_SERVER_METHOD_PARAM_TYPES, &mut type_names); + collect_experimental_type_names(EXPERIMENTAL_SERVER_METHOD_RESPONSE_TYPES, &mut type_names); type_names } @@ -2118,6 +2127,13 @@ mod tests { client_request_ts.contains("MockExperimentalMethodParams"), false ); + let server_request_ts = std::str::from_utf8( + fixture_tree + .get(Path::new("ServerRequest.ts")) + .ok_or_else(|| anyhow::anyhow!("missing ServerRequest.ts fixture"))?, + )?; + assert_eq!(server_request_ts.contains("currentTime/read"), false); + assert_eq!(server_request_ts.contains("CurrentTimeReadParams"), false); let typescript_index = std::str::from_utf8( fixture_tree .get(Path::new("index.ts")) @@ -2138,6 +2154,14 @@ mod tests { fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")), false ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/CurrentTimeReadParams.ts")), + false + ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/CurrentTimeReadResponse.ts")), + false + ); assert_eq!( fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")), false diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7aa83f863789..d39fdf5fc51a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1027,6 +1027,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"), @@ -1108,6 +1114,11 @@ client_request_definitions! { serialization: global("config"), response: v2::ExternalAgentConfigImportResponse, }, + ExternalAgentConfigImportHistoriesRead => "externalAgentConfig/import/readHistories" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("config"), + response: v2::ExternalAgentConfigImportHistoriesReadResponse, + }, ConfigValueWrite => "config/value/write" { params: v2::ConfigValueWriteParams, serialization: global("config"), @@ -1184,7 +1195,8 @@ client_request_definitions! { macro_rules! server_request_definitions { ( $( - $(#[$variant_meta:meta])* + $(#[experimental($reason:expr)])? + $(#[doc = $variant_doc:literal])* $variant:ident $(=> $wire:literal)? { params: $params:ty, response: $response:ty, @@ -1197,7 +1209,7 @@ macro_rules! server_request_definitions { #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerRequest { $( - $(#[$variant_meta])* + $(#[doc = $variant_doc])* $(#[serde(rename = $wire)] #[ts(rename = $wire)])? $variant { #[serde(rename = "id")] @@ -1237,7 +1249,7 @@ macro_rules! server_request_definitions { #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerResponse { $( - $(#[$variant_meta])* + $(#[doc = $variant_doc])* $(#[serde(rename = $wire)])? $variant { #[serde(rename = "id")] @@ -1281,6 +1293,22 @@ macro_rules! server_request_definitions { } } + pub(crate) const EXPERIMENTAL_SERVER_METHODS: &[&str] = &[ + $( + experimental_method_entry!($(#[experimental($reason)])? $(=> $wire)?), + )* + ]; + pub(crate) const EXPERIMENTAL_SERVER_METHOD_PARAM_TYPES: &[&str] = &[ + $( + experimental_type_entry!($(#[experimental($reason)])? $params), + )* + ]; + pub(crate) const EXPERIMENTAL_SERVER_METHOD_RESPONSE_TYPES: &[&str] = &[ + $( + experimental_type_entry!($(#[experimental($reason)])? $response), + )* + ]; + pub fn export_server_responses( out_dir: &::std::path::Path, ) -> ::std::result::Result<(), ::ts_rs::ExportError> { @@ -1470,6 +1498,13 @@ server_request_definitions! { response: v2::AttestationGenerateResponse, }, + #[experimental("currentTime/read")] + /// Read the current time from an external clock owned by the client. + CurrentTimeRead => "currentTime/read" { + params: v2::CurrentTimeReadParams, + response: v2::CurrentTimeReadResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). @@ -1619,6 +1654,7 @@ server_notification_definitions! { AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification), RemoteControlStatusChanged => "remoteControl/status/changed" (v2::RemoteControlStatusChangedNotification), + ExternalAgentConfigImportProgress => "externalAgentConfig/import/progress" (v2::ExternalAgentConfigImportProgressNotification), ExternalAgentConfigImportCompleted => "externalAgentConfig/import/completed" (v2::ExternalAgentConfigImportCompletedNotification), FsChanged => "fs/changed" (v2::FsChangedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), @@ -1630,6 +1666,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), @@ -1673,10 +1710,11 @@ mod tests { use super::*; use anyhow::Result; 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::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RealtimeVoice; @@ -1986,6 +2024,7 @@ mod tests { params: v2::EnvironmentAddParams { environment_id: "remote-a".to_string(), exec_server_url: "ws://127.0.0.1:8765".to_string(), + connect_timeout_ms: None, }, }; assert_eq!( @@ -2148,7 +2187,7 @@ mod tests { } #[test] - fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> { + fn serialize_initialize_capabilities() -> Result<()> { let request = ClientRequest::Initialize { request_id: RequestId::Integer(42), params: v1::InitializeParams { @@ -2160,6 +2199,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2181,6 +2221,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, + "mcpServerOpenaiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2194,7 +2235,7 @@ mod tests { } #[test] - fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> { + fn deserialize_initialize_capabilities() -> Result<()> { let request: ClientRequest = serde_json::from_value(json!({ "method": "initialize", "id": 42, @@ -2207,6 +2248,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, + "mcpServerOpenaiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2228,6 +2270,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2366,6 +2409,32 @@ mod tests { Ok(()) } + #[test] + fn serialize_current_time_read_request() -> Result<()> { + let params = v2::CurrentTimeReadParams { + thread_id: "thread-123".to_string(), + }; + let request = ServerRequest::CurrentTimeRead { + request_id: RequestId::Integer(10), + params: params.clone(), + }; + assert_eq!( + json!({ + "method": "currentTime/read", + "id": 10, + "params": { + "threadId": "thread-123" + } + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::CurrentTimeRead(params); + assert_eq!(request.id(), &RequestId::Integer(10)); + assert_eq!(payload.request_with_id(RequestId::Integer(10)), request); + Ok(()) + } + #[test] fn serialize_server_response() -> Result<()> { let response = ServerResponse::CommandExecutionRequestApproval { @@ -2483,6 +2552,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"); @@ -2499,6 +2586,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(3), status: v2::ThreadStatus::Idle, path: None, cwd: cwd.clone(), @@ -2516,12 +2604,17 @@ mod tests { service_tier: None, cwd, runtime_workspace_roots: Vec::new(), - instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], + instruction_sources: vec![ + codex_utils_path_uri::LegacyAppPathString::from_abs_path(&absolute_path( + "/tmp/AGENTS.md", + )), + ], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, }, }; @@ -2542,6 +2635,7 @@ mod tests { "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, + "recencyAt": 3, "status": { "type": "idle" }, @@ -2568,7 +2662,8 @@ mod tests { "type": "dangerFullAccess" }, "activePermissionProfile": null, - "reasoningEffort": null + "reasoningEffort": null, + "multiAgentMode": "explicitRequestOnly" } }), serde_json::to_value(&response)?, @@ -2764,7 +2859,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!( @@ -2776,6 +2871,54 @@ 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, + }; + assert_eq!( + json!({ + "type": "amazonBedrock", + "credentialSource": "codexManaged", + }), + serde_json::to_value(&codex_managed_bedrock)?, + ); + + let aws_managed_bedrock = v2::Account::AmazonBedrock { + credential_source: AmazonBedrockCredentialSource::AwsManaged, + }; + assert_eq!( + json!({ + "type": "amazonBedrock", + "credentialSource": "awsManaged", + }), + serde_json::to_value(&aws_managed_bedrock)?, + ); + + Ok(()) + } + + #[test] + fn account_defaults_legacy_bedrock_credential_source() -> Result<()> { + assert_eq!( + v2::Account::AmazonBedrock { + credential_source: AmazonBedrockCredentialSource::AwsManaged, + }, + serde_json::from_value(json!({ + "type": "amazonBedrock", + }))?, + ); Ok(()) } @@ -2862,6 +3005,7 @@ mod tests { params: v2::EnvironmentAddParams { environment_id: "remote-a".to_string(), exec_server_url: "ws://127.0.0.1:8765".to_string(), + connect_timeout_ms: Some(300_000), }, }; assert_eq!( @@ -2870,7 +3014,8 @@ mod tests { "id": 9, "params": { "environmentId": "remote-a", - "execServerUrl": "ws://127.0.0.1:8765" + "execServerUrl": "ws://127.0.0.1:8765", + "connectTimeoutMs": 300000 } }), serde_json::to_value(&request)?, @@ -3042,9 +3187,10 @@ mod tests { let request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { - architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: Some(true), codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: Some("silent context".to_string()), thread_id: "thr_123".to_string(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, @@ -3061,10 +3207,11 @@ mod tests { "method": "thread/realtime/start", "id": 9, "params": { - "architecture": "avas", "threadId": "thr_123", + "clientManagedHandoffs": true, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": "silent context", "model": "realtime-treatment-model", "outputModality": "audio", "includeStartupContext": false, @@ -3085,9 +3232,10 @@ mod tests { let default_prompt_request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -3104,10 +3252,11 @@ mod tests { "method": "thread/realtime/start", "id": 9, "params": { - "architecture": null, "threadId": "thr_123", + "clientManagedHandoffs": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": null, "model": null, "outputModality": "audio", "includeStartupContext": null, @@ -3123,9 +3272,10 @@ mod tests { let null_prompt_request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -3142,10 +3292,11 @@ mod tests { "method": "thread/realtime/start", "id": 9, "params": { - "architecture": null, "threadId": "thr_123", + "clientManagedHandoffs": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": null, "model": null, "outputModality": "audio", "includeStartupContext": null, @@ -3240,6 +3391,33 @@ 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: "gpt-5.4".to_string(), + use_cases: vec!["cyber".to_string()], + reasons: vec!["user_risk".to_string()], + }, + ); + assert_eq!( + json!({ + "method": "model/safetyBuffering/updated", + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "model": "gpt-5.4", + "useCases": ["cyber"], + "reasons": ["user_risk"] + } + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + #[test] fn serialize_thread_realtime_output_audio_delta_notification() -> Result<()> { let notification = ServerNotification::ThreadRealtimeOutputAudioDelta( @@ -3290,6 +3468,7 @@ mod tests { params: v2::EnvironmentAddParams { environment_id: "remote-a".to_string(), exec_server_url: "ws://127.0.0.1:8765".to_string(), + connect_timeout_ms: None, }, }; let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); @@ -3327,9 +3506,10 @@ mod tests { let request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(1), params: v2::ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -3438,6 +3618,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, }, }); @@ -3501,6 +3682,7 @@ mod tests { item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: None, network_approval_context: None, command: Some("cat file".to_string()), diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 17e0f9aef48a..66019980a3c8 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -23,6 +23,7 @@ use crate::protocol::v2::PatchApplyStatus; use crate::protocol::v2::PatchChangeKind; use crate::protocol::v2::ThreadItem; use codex_protocol::ThreadId; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::ExecCommandBeginEvent; @@ -34,8 +35,11 @@ use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; +use tracing::warn; pub fn build_file_change_approval_request_item( payload: &ApplyPatchApprovalRequestEvent, @@ -69,7 +73,7 @@ pub fn build_command_execution_approval_request_item( ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -86,19 +90,15 @@ pub fn build_command_execution_approval_request_item( } pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { + let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -112,26 +112,63 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread Some(payload.aggregated_output.clone()) }; let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); + let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions, aggregated_output, exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), } } +fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Vec { + // TODO(anp): Carry PathUri into CommandAction so foreign Read actions retain resolved paths. + // Until then, omit those actions rather than project a foreign cwd onto the host. + let native_cwd = if cwd.infer_path_convention() == Some(PathConvention::native()) { + cwd.to_abs_path().ok() + } else { + None + }; + + parsed_cmd + .iter() + .cloned() + .filter_map(|parsed| match parsed { + ParsedCommand::Read { cmd, name, path } => match native_cwd.as_ref() { + Some(native_cwd) => Some(CommandAction::Read { + command: cmd, + name, + path: native_cwd.join(path), + }), + None => { + warn!( + command = cmd, + %cwd, + "omitting read command action whose path cannot be resolved against a foreign cwd" + ); + None + } + }, + ParsedCommand::ListFiles { cmd, path } => { + Some(CommandAction::ListFiles { command: cmd, path }) + } + ParsedCommand::Search { cmd, query, path } => Some(CommandAction::Search { + command: cmd, + query, + path, + }), + ParsedCommand::Unknown { cmd } => Some(CommandAction::Unknown { command: cmd }), + }) + .collect() +} + /// Build a guardian-derived [`ThreadItem`]. /// /// Currently this only synthesizes [`ThreadItem::CommandExecution`] for @@ -150,7 +187,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status, @@ -186,7 +223,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status, @@ -315,3 +352,7 @@ fn format_file_change_diff(change: &FileChange) -> String { } } } + +#[cfg(test)] +#[path = "item_builders_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs new file mode 100644 index 000000000000..b892fcf505db --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -0,0 +1,41 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn foreign_read_is_omitted_without_dropping_other_command_actions() { + #[cfg(windows)] + let cwd = PathUri::parse("file:///usr/local/src").expect("valid foreign POSIX cwd"); + #[cfg(not(windows))] + let cwd = PathUri::parse("file:///C:/src").expect("valid foreign Windows cwd"); + let parsed_cmd = vec![ + ParsedCommand::Read { + cmd: "cat file.txt".to_string(), + name: "file.txt".to_string(), + path: PathBuf::from("file.txt"), + }, + ParsedCommand::ListFiles { + cmd: "ls".to_string(), + path: Some("subdir".to_string()), + }, + ParsedCommand::Search { + cmd: "rg needle".to_string(), + query: Some("needle".to_string()), + path: Some("src".to_string()), + }, + ]; + + assert_eq!( + command_actions_for_path_uri(&parsed_cmd, &cwd), + vec![ + CommandAction::ListFiles { + command: "ls".to_string(), + path: Some("subdir".to_string()), + }, + CommandAction::Search { + command: "rg needle".to_string(), + query: Some("needle".to_string()), + path: Some("src".to_string()), + }, + ] + ); +} 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 6ca038981fc1..1e087c4c5fb4 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -10,6 +10,7 @@ use crate::protocol::v2::CollabAgentToolCallStatus; use crate::protocol::v2::CommandExecutionStatus; use crate::protocol::v2::DynamicToolCallOutputContentItem; use crate::protocol::v2::DynamicToolCallStatus; +use crate::protocol::v2::McpToolCallAppContext; use crate::protocol::v2::McpToolCallError; use crate::protocol::v2::McpToolCallResult; use crate::protocol::v2::McpToolCallStatus; @@ -83,12 +84,154 @@ pub fn build_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { builder.finish() } +/// A materialized `ThreadItem` snapshot that changed while handling one input. +#[derive(Debug, Clone, PartialEq)] +pub struct ThreadHistoryItemChange { + pub turn_id: String, + pub item: ThreadItem, +} + +/// Lightweight turn metadata snapshot for projectors that track turn status without +/// re-reading the full item list. +#[derive(Debug, Clone, PartialEq)] +pub struct ThreadHistoryTurnChange { + pub turn_id: String, + pub status: TurnStatus, + pub error: Option, + pub started_at: Option, + pub completed_at: Option, + pub duration_ms: Option, +} + +/// Incremental changes produced by opt-in `ThreadHistoryBuilder` handlers. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ThreadHistoryChangeSet { + pub changed_items: Vec, + pub changed_turns: Vec, + pub removed_turn_ids: Vec, +} + +impl ThreadHistoryChangeSet { + pub fn is_empty(&self) -> bool { + self.changed_items.is_empty() + && self.changed_turns.is_empty() + && self.removed_turn_ids.is_empty() + } +} + +impl ThreadHistoryTurnChange { + fn from_pending_turn(turn: &PendingTurn) -> Self { + Self { + turn_id: turn.id.clone(), + status: turn.status.clone(), + error: turn.error.clone(), + started_at: turn.started_at, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, + } + } + + fn from_turn(turn: &Turn) -> Self { + Self { + turn_id: turn.id.clone(), + status: turn.status.clone(), + error: turn.error.clone(), + started_at: turn.started_at, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, + } + } +} + +/// Coalesces per-rollout-item changes into an end-of-batch view. It preserves +/// first-change order while replacing repeated item/turn snapshots with their +/// latest value, and drops accumulated changes for turns removed by rollback. +#[derive(Default)] +struct ThreadHistoryChangeAccumulator { + changed_items: Vec>, + changed_item_indexes: HashMap<(String, String), usize>, + changed_turns: Vec>, + changed_turn_indexes: HashMap, + removed_turn_ids: Vec, + removed_turn_indexes: HashMap, +} + +impl ThreadHistoryChangeAccumulator { + fn push(&mut self, changes: ThreadHistoryChangeSet) { + for turn_id in changes.removed_turn_ids { + self.push_removed_turn_id(turn_id); + } + for item_change in changes.changed_items { + self.push_item_change(item_change); + } + for turn_change in changes.changed_turns { + self.push_turn_change(turn_change); + } + } + + fn finish(self) -> ThreadHistoryChangeSet { + ThreadHistoryChangeSet { + changed_items: self.changed_items.into_iter().flatten().collect(), + changed_turns: self.changed_turns.into_iter().flatten().collect(), + removed_turn_ids: self.removed_turn_ids, + } + } + + fn push_item_change(&mut self, change: ThreadHistoryItemChange) { + let key = (change.turn_id.clone(), change.item.id().to_string()); + if let Some(index) = self.changed_item_indexes.get(&key).copied() { + self.changed_items[index] = Some(change); + return; + } + + self.changed_item_indexes + .insert(key, self.changed_items.len()); + self.changed_items.push(Some(change)); + } + + fn push_turn_change(&mut self, change: ThreadHistoryTurnChange) { + if let Some(index) = self.changed_turn_indexes.get(&change.turn_id).copied() { + self.changed_turns[index] = Some(change); + return; + } + + self.changed_turn_indexes + .insert(change.turn_id.clone(), self.changed_turns.len()); + self.changed_turns.push(Some(change)); + } + + fn push_removed_turn_id(&mut self, turn_id: String) { + if !self.removed_turn_indexes.contains_key(&turn_id) { + self.removed_turn_indexes + .insert(turn_id.clone(), self.removed_turn_ids.len()); + self.removed_turn_ids.push(turn_id.clone()); + } + + if let Some(index) = self.changed_turn_indexes.remove(&turn_id) { + self.changed_turns[index] = None; + } + + let removed_item_keys: Vec<(String, String)> = self + .changed_item_indexes + .keys() + .filter(|(item_turn_id, _)| item_turn_id == &turn_id) + .cloned() + .collect(); + for key in removed_item_keys { + if let Some(index) = self.changed_item_indexes.remove(&key) { + self.changed_items[index] = None; + } + } + } +} + pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, next_item_index: i64, current_rollout_index: usize, next_rollout_index: usize, + active_change_set: Option, } impl Default for ThreadHistoryBuilder { @@ -105,6 +248,7 @@ impl ThreadHistoryBuilder { next_item_index: 1, current_rollout_index: 0, next_rollout_index: 0, + active_change_set: None, } } @@ -247,6 +391,42 @@ impl ThreadHistoryBuilder { } } + /// Handles one event and returns the materialized items or turn metadata + /// changed by that event. + pub fn handle_event_with_changes(&mut self, event: &EventMsg) -> ThreadHistoryChangeSet { + self.collect_changes(|builder| builder.handle_event(event)) + } + + /// Handles a rollout item and returns the materialized items or turn metadata + /// changed by that one append. + pub fn handle_rollout_item_with_changes( + &mut self, + item: &RolloutItem, + ) -> ThreadHistoryChangeSet { + self.collect_changes(|builder| builder.handle_rollout_item(item)) + } + + /// Handles rollout items in order and returns a coalesced end-of-batch + /// change set. Multiple changes to the same item or turn are deduplicated + /// so only the latest snapshot is emitted. + pub fn handle_rollout_items_with_changes( + &mut self, + items: &[RolloutItem], + ) -> ThreadHistoryChangeSet { + let mut accumulator = ThreadHistoryChangeAccumulator::default(); + for item in items { + accumulator.push(self.handle_rollout_item_with_changes(item)); + } + accumulator.finish() + } + + fn collect_changes(&mut self, handle: impl FnOnce(&mut Self)) -> ThreadHistoryChangeSet { + debug_assert!(self.active_change_set.is_none()); + self.active_change_set = Some(ThreadHistoryChangeSet::default()); + handle(self); + self.active_change_set.take().unwrap_or_default() + } + fn handle_response_item(&mut self, item: &codex_protocol::models::ResponseItem) { let codex_protocol::models::ResponseItem::Message { role, content, id, .. @@ -263,7 +443,7 @@ impl ThreadHistoryBuilder { return; }; - self.ensure_turn().items.push(ThreadItem::HookPrompt { + self.push_item_in_current_turn(ThreadItem::HookPrompt { id: hook_prompt.id, fragments: hook_prompt .fragments @@ -283,18 +463,13 @@ impl ThreadHistoryBuilder { { self.finish_current_turn(); } - let mut turn = self - .current_turn - .take() - .unwrap_or_else(|| self.new_turn(/*id*/ None)); let id = self.next_item_id(); let content = self.build_user_inputs(payload); - turn.items.push(ThreadItem::UserMessage { + self.push_item_in_current_turn(ThreadItem::UserMessage { id, client_id: payload.client_id.clone(), content, }); - self.current_turn = Some(turn); } fn handle_agent_message( @@ -308,7 +483,7 @@ impl ThreadHistoryBuilder { } let id = self.next_item_id(); - self.ensure_turn().items.push(ThreadItem::AgentMessage { + self.push_item_in_current_turn(ThreadItem::AgentMessage { id, text, phase, @@ -322,14 +497,34 @@ impl ThreadHistoryBuilder { } // If the last item is a reasoning item, add the new text to the summary. - if let Some(ThreadItem::Reasoning { summary, .. }) = self.ensure_turn().items.last_mut() { - summary.push(payload.text.clone()); + let existing_item_change = { + let tracking_changes = self.is_tracking_changes(); + let turn = self.ensure_turn(); + if let Some(ThreadItem::Reasoning { summary, .. }) = turn.items.last_mut() { + summary.push(payload.text.clone()); + let changed_item = if tracking_changes { + turn.items + .last() + .cloned() + .map(|item| (turn.id.clone(), item)) + } else { + None + }; + Some(changed_item) + } else { + None + } + }; + if let Some(changed_item) = existing_item_change { + if let Some((turn_id, item)) = changed_item { + self.record_changed_item(turn_id, item); + } return; } // Otherwise, create a new reasoning item. let id = self.next_item_id(); - self.ensure_turn().items.push(ThreadItem::Reasoning { + self.push_item_in_current_turn(ThreadItem::Reasoning { id, summary: vec![payload.text.clone()], content: Vec::new(), @@ -342,14 +537,34 @@ impl ThreadHistoryBuilder { } // If the last item is a reasoning item, add the new text to the content. - if let Some(ThreadItem::Reasoning { content, .. }) = self.ensure_turn().items.last_mut() { - content.push(payload.text.clone()); + let existing_item_change = { + let tracking_changes = self.is_tracking_changes(); + let turn = self.ensure_turn(); + if let Some(ThreadItem::Reasoning { content, .. }) = turn.items.last_mut() { + content.push(payload.text.clone()); + let changed_item = if tracking_changes { + turn.items + .last() + .cloned() + .map(|item| (turn.id.clone(), item)) + } else { + None + }; + Some(changed_item) + } else { + None + } + }; + if let Some(changed_item) = existing_item_change { + if let Some((turn_id, item)) = changed_item { + self.record_changed_item(turn_id, item); + } return; } // Otherwise, create a new reasoning item. let id = self.next_item_id(); - self.ensure_turn().items.push(ThreadItem::Reasoning { + self.push_item_in_current_turn(ThreadItem::Reasoning { id, summary: Vec::new(), content: vec![payload.text.clone()], @@ -551,6 +766,14 @@ impl ThreadHistoryBuilder { .arguments .clone() .unwrap_or(serde_json::Value::Null), + app_context: payload + .connector_id + .clone() + .map(|connector_id| McpToolCallAppContext { + connector_id, + link_id: payload.link_id.clone(), + resource_uri: payload.mcp_app_resource_uri.clone(), + }), mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), plugin_id: payload.plugin_id.clone(), result: None, @@ -593,6 +816,14 @@ impl ThreadHistoryBuilder { .arguments .clone() .unwrap_or(serde_json::Value::Null), + app_context: payload + .connector_id + .clone() + .map(|connector_id| McpToolCallAppContext { + connector_id, + link_id: payload.link_id.clone(), + resource_uri: payload.mcp_app_resource_uri.clone(), + }), mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), plugin_id: payload.plugin_id.clone(), result, @@ -884,9 +1115,7 @@ impl ThreadHistoryBuilder { fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) { let id = self.next_item_id(); - self.ensure_turn() - .items - .push(ThreadItem::ContextCompaction { id }); + self.push_item_in_current_turn(ThreadItem::ContextCompaction { id }); } fn handle_entered_review_mode(&mut self, payload: &codex_protocol::protocol::ReviewRequest) { @@ -895,9 +1124,7 @@ impl ThreadHistoryBuilder { .clone() .unwrap_or_else(|| "Review requested.".to_string()); let id = self.next_item_id(); - self.ensure_turn() - .items - .push(ThreadItem::EnteredReviewMode { id, review }); + self.push_item_in_current_turn(ThreadItem::EnteredReviewMode { id, review }); } fn handle_exited_review_mode( @@ -910,24 +1137,28 @@ impl ThreadHistoryBuilder { .map(render_review_output_text) .unwrap_or_else(|| REVIEW_FALLBACK_MESSAGE.to_string()); let id = self.next_item_id(); - self.ensure_turn() - .items - .push(ThreadItem::ExitedReviewMode { id, review }); + self.push_item_in_current_turn(ThreadItem::ExitedReviewMode { id, review }); } fn handle_error(&mut self, payload: &ErrorEvent) { if !payload.affects_turn_status() { return; } - let Some(turn) = self.current_turn.as_mut() else { - return; + let tracking_changes = self.is_tracking_changes(); + let changed_turn = if let Some(turn) = self.current_turn.as_mut() { + turn.status = TurnStatus::Failed; + turn.error = Some(V2TurnError { + message: payload.message.clone(), + codex_error_info: payload.codex_error_info.clone().map(Into::into), + additional_details: None, + }); + tracking_changes.then(|| ThreadHistoryTurnChange::from_pending_turn(turn)) + } else { + None }; - turn.status = TurnStatus::Failed; - turn.error = Some(V2TurnError { - message: payload.message.clone(), - codex_error_info: payload.codex_error_info.clone().map(Into::into), - additional_details: None, - }); + if let Some(changed_turn) = changed_turn { + self.record_changed_turn(changed_turn); + } } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { @@ -935,11 +1166,13 @@ impl ThreadHistoryBuilder { turn.status = TurnStatus::Interrupted; turn.completed_at = payload.completed_at; turn.duration_ms = payload.duration_ms; + ThreadHistoryTurnChange::from_pending_turn(turn) }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - apply_abort(turn); + let changed_turn = apply_abort(turn); + self.record_changed_turn(changed_turn); return; } @@ -947,24 +1180,28 @@ impl ThreadHistoryBuilder { turn.status = TurnStatus::Interrupted; turn.completed_at = payload.completed_at; turn.duration_ms = payload.duration_ms; + let changed_turn = ThreadHistoryTurnChange::from_turn(turn); + self.record_changed_turn(changed_turn); return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - apply_abort(turn); + let changed_turn = apply_abort(turn); + self.record_changed_turn(changed_turn); } } fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { self.finish_current_turn(); - self.current_turn = Some( - self.new_turn(Some(payload.turn_id.clone())) - .with_status(TurnStatus::InProgress) - .with_started_at(payload.started_at) - .opened_explicitly(), - ); + let turn = self + .new_turn(Some(payload.turn_id.clone())) + .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) + .opened_explicitly(); + self.record_changed_pending_turn(&turn); + self.current_turn = Some(turn); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { @@ -974,6 +1211,7 @@ impl ThreadHistoryBuilder { } turn.completed_at = payload.completed_at; turn.duration_ms = payload.duration_ms; + ThreadHistoryTurnChange::from_pending_turn(turn) }; // Prefer an exact ID match from the active turn and then close it. @@ -982,7 +1220,8 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(current_turn); + let changed_turn = mark_completed(current_turn); + self.record_changed_turn(changed_turn); self.finish_current_turn(); return; } @@ -997,12 +1236,15 @@ impl ThreadHistoryBuilder { } turn.completed_at = payload.completed_at; turn.duration_ms = payload.duration_ms; + let changed_turn = ThreadHistoryTurnChange::from_turn(turn); + self.record_changed_turn(changed_turn); return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(current_turn); + let changed_turn = mark_completed(current_turn); + self.record_changed_turn(changed_turn); self.finish_current_turn(); } } @@ -1020,6 +1262,18 @@ impl ThreadHistoryBuilder { self.finish_current_turn(); let n = usize::try_from(payload.num_turns).unwrap_or(usize::MAX); + let removed_turn_ids = if n >= self.turns.len() { + self.turns.iter().map(|turn| turn.id.clone()).collect() + } else if n == 0 { + Vec::new() + } else { + self.turns[self.turns.len() - n..] + .iter() + .map(|turn| turn.id.clone()) + .collect() + }; + self.record_removed_turn_ids(removed_turn_ids); + if n >= self.turns.len() { self.turns.clear(); } else { @@ -1064,7 +1318,8 @@ impl ThreadHistoryBuilder { fn ensure_turn(&mut self) -> &mut PendingTurn { if self.current_turn.is_none() { let turn = self.new_turn(/*id*/ None); - return self.current_turn.insert(turn); + self.record_changed_pending_turn(&turn); + self.current_turn = Some(turn); } if let Some(turn) = self.current_turn.as_mut() { @@ -1074,16 +1329,42 @@ impl ThreadHistoryBuilder { unreachable!("current turn must exist after initialization"); } + fn push_item_in_current_turn(&mut self, item: ThreadItem) { + let tracking_changes = self.is_tracking_changes(); + let changed_item = { + let turn = self.ensure_turn(); + let changed_item = tracking_changes.then(|| (turn.id.clone(), item.clone())); + turn.items.push(item); + changed_item + }; + if let Some((turn_id, item)) = changed_item { + self.record_changed_item(turn_id, item); + } + } + fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItem) { + let tracking_changes = self.is_tracking_changes(); if let Some(turn) = self.current_turn.as_mut() && turn.id == turn_id { - upsert_turn_item(&mut turn.items, item); + let changed_item = { + let item = upsert_turn_item(&mut turn.items, item); + tracking_changes.then(|| (turn.id.clone(), item.clone())) + }; + if let Some((turn_id, item)) = changed_item { + self.record_changed_item(turn_id, item); + } return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { - upsert_turn_item(&mut turn.items, item); + let changed_item = { + let item = upsert_turn_item(&mut turn.items, item); + tracking_changes.then(|| (turn.id.clone(), item.clone())) + }; + if let Some((turn_id, item)) = changed_item { + self.record_changed_item(turn_id, item); + } return; } @@ -1094,8 +1375,45 @@ impl ThreadHistoryBuilder { } fn upsert_item_in_current_turn(&mut self, item: ThreadItem) { - let turn = self.ensure_turn(); - upsert_turn_item(&mut turn.items, item); + let tracking_changes = self.is_tracking_changes(); + let changed_item = { + let turn = self.ensure_turn(); + let item = upsert_turn_item(&mut turn.items, item); + tracking_changes.then(|| (turn.id.clone(), item.clone())) + }; + if let Some((turn_id, item)) = changed_item { + self.record_changed_item(turn_id, item); + } + } + + fn is_tracking_changes(&self) -> bool { + self.active_change_set.is_some() + } + + fn record_changed_item(&mut self, turn_id: String, item: ThreadItem) { + if let Some(change_set) = self.active_change_set.as_mut() { + change_set + .changed_items + .push(ThreadHistoryItemChange { turn_id, item }); + } + } + + fn record_changed_pending_turn(&mut self, turn: &PendingTurn) { + if self.is_tracking_changes() { + self.record_changed_turn(ThreadHistoryTurnChange::from_pending_turn(turn)); + } + } + + fn record_changed_turn(&mut self, turn: ThreadHistoryTurnChange) { + if let Some(change_set) = self.active_change_set.as_mut() { + change_set.changed_turns.push(turn); + } + } + + fn record_removed_turn_ids(&mut self, removed_turn_ids: Vec) { + if let Some(change_set) = self.active_change_set.as_mut() { + change_set.removed_turn_ids.extend(removed_turn_ids); + } } fn next_item_id(&mut self) -> String { @@ -1163,15 +1481,17 @@ fn convert_dynamic_tool_content_items( .collect() } -fn upsert_turn_item(items: &mut Vec, item: ThreadItem) { - if let Some(existing_item) = items - .iter_mut() - .find(|existing_item| existing_item.id() == item.id()) +fn upsert_turn_item(items: &mut Vec, item: ThreadItem) -> &ThreadItem { + if let Some(existing_item_index) = items + .iter() + .position(|existing_item| existing_item.id() == item.id()) { - *existing_item = item; - return; + items[existing_item_index] = item; + return &items[existing_item_index]; } + let inserted_item_index = items.len(); items.push(item); + &items[inserted_item_index] } struct PendingTurn { @@ -1264,7 +1584,6 @@ mod tests { use codex_protocol::protocol::DynamicToolCallResponseEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; - use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallEndEvent; @@ -1275,6 +1594,7 @@ mod tests { use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; + use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -2072,7 +2392,7 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["echo".into(), "hello world".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello world".into(), }], @@ -2093,7 +2413,9 @@ mod tests { tool: "lookup".into(), arguments: Some(serde_json::json!({"id":"123"})), }, + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, duration: Duration::from_millis(8), result: Err("boom".into()), @@ -2123,7 +2445,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-1".into(), command: "echo 'hello world'".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-1".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, @@ -2143,6 +2465,7 @@ mod tests { tool: "lookup".into(), status: McpToolCallStatus::Failed, arguments: serde_json::json!({"id":"123"}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -2171,7 +2494,9 @@ mod tests { tool: "lookup".into(), arguments: Some(serde_json::json!({"id":"123"})), }, + connector_id: Some("calendar".into()), mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), + link_id: Some("link_calendar".into()), plugin_id: Some("sample@test".into()), duration: Duration::from_millis(8), result: Ok(CallToolResult { @@ -2202,6 +2527,11 @@ mod tests { tool: "lookup".into(), status: McpToolCallStatus::Completed, arguments: serde_json::json!({"id":"123"}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".into(), + link_id: Some("link_calendar".into()), + resource_uri: Some("ui://widget/lookup.html".into()), + }), mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), plugin_id: Some("sample@test".into()), result: Some(Box::new(McpToolCallResult { @@ -2312,7 +2642,7 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["ls".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], source: ExecCommandSource::Agent, interaction_input: None, @@ -2354,7 +2684,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-declined".into(), command: "ls".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-2".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2452,7 +2782,7 @@ mod tests { ThreadItem::CommandExecution { id: "guardian-exec".into(), command: "rm -rf /tmp/guardian".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2518,7 +2848,7 @@ mod tests { ThreadItem::CommandExecution { id: "guardian-execve".into(), command: "/bin/rm -f /tmp/file.sqlite".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -2578,7 +2908,7 @@ mod tests { turn_id: "turn-a".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], @@ -2616,7 +2946,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-late".into(), command: "echo done".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-42".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, @@ -2676,7 +3006,7 @@ mod tests { turn_id: "turn-missing".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], @@ -3010,6 +3340,9 @@ mod tests { RolloutItem::Compacted(CompactedItem { 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 { @@ -3442,7 +3775,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(), @@ -3457,4 +3790,299 @@ mod tests { assert_eq!(turns.len(), 1); assert!(turns[0].items.is_empty()); } + + #[test] + fn changed_rollout_item_reports_new_item_snapshot() { + let mut builder = ThreadHistoryBuilder::new(); + + let changes = builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg( + EventMsg::UserMessage(UserMessageEvent { + client_id: Some("client-message-1".into()), + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + ..Default::default() + }), + )); + + assert_eq!( + changes, + ThreadHistoryChangeSet { + changed_items: vec![ThreadHistoryItemChange { + turn_id: "rollout-0".into(), + item: ThreadItem::UserMessage { + id: "item-1".into(), + client_id: Some("client-message-1".into()), + content: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + }, + }], + changed_turns: vec![ThreadHistoryTurnChange { + turn_id: "rollout-0".into(), + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + removed_turn_ids: Vec::new(), + } + ); + } + + #[test] + fn changed_rollout_item_reports_updated_existing_item_snapshot() { + let mut builder = ThreadHistoryBuilder::new(); + builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg(EventMsg::WebSearchBegin( + WebSearchBeginEvent { + call_id: "search-1".into(), + }, + ))); + + let changes = builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg( + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: "search-1".into(), + query: "codex".into(), + action: CoreWebSearchAction::Search { + query: Some("codex".into()), + queries: None, + }, + }), + )); + + assert_eq!( + changes, + ThreadHistoryChangeSet { + changed_items: vec![ThreadHistoryItemChange { + turn_id: "rollout-0".into(), + item: ThreadItem::WebSearch { + id: "search-1".into(), + query: "codex".into(), + action: Some(WebSearchAction::Search { + query: Some("codex".into()), + queries: None, + }), + }, + }], + changed_turns: Vec::new(), + removed_turn_ids: Vec::new(), + } + ); + } + + #[test] + fn changed_rollout_item_reports_streaming_item_mutation() { + let mut builder = ThreadHistoryBuilder::new(); + builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg(EventMsg::AgentReasoning( + AgentReasoningEvent { + text: "summary".into(), + }, + ))); + + let changes = builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg( + EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { + text: "raw content".into(), + }), + )); + + assert_eq!( + changes, + ThreadHistoryChangeSet { + changed_items: vec![ThreadHistoryItemChange { + turn_id: "rollout-0".into(), + item: ThreadItem::Reasoning { + id: "item-1".into(), + summary: vec!["summary".into()], + content: vec!["raw content".into()], + }, + }], + changed_turns: Vec::new(), + removed_turn_ids: Vec::new(), + } + ); + } + + #[test] + fn changed_rollout_item_reports_turn_completion_metadata() { + let mut builder = ThreadHistoryBuilder::new(); + + let start_changes = builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg( + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + trace_id: None, + started_at: Some(10), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + )); + assert_eq!( + start_changes, + ThreadHistoryChangeSet { + changed_items: Vec::new(), + changed_turns: vec![ThreadHistoryTurnChange { + turn_id: "turn-a".into(), + status: TurnStatus::InProgress, + error: None, + started_at: Some(10), + completed_at: None, + duration_ms: None, + }], + removed_turn_ids: Vec::new(), + } + ); + + builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + client_id: None, + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + ..Default::default() + }, + ))); + let complete_changes = builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg( + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: Some(20), + duration_ms: Some(123), + time_to_first_token_ms: None, + }), + )); + + assert_eq!( + complete_changes, + ThreadHistoryChangeSet { + changed_items: Vec::new(), + changed_turns: vec![ThreadHistoryTurnChange { + turn_id: "turn-a".into(), + status: TurnStatus::Completed, + error: None, + started_at: Some(10), + completed_at: Some(20), + duration_ms: Some(123), + }], + removed_turn_ids: Vec::new(), + } + ); + } + + #[test] + fn changed_rollout_items_dedupe_updated_item_snapshots() { + let mut builder = ThreadHistoryBuilder::new(); + let changes = builder.handle_rollout_items_with_changes(&[ + RolloutItem::EventMsg(EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: "search-1".into(), + })), + RolloutItem::EventMsg(EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: "search-1".into(), + query: "codex".into(), + action: CoreWebSearchAction::Search { + query: Some("codex".into()), + queries: None, + }, + })), + ]); + + assert_eq!( + changes, + ThreadHistoryChangeSet { + changed_items: vec![ThreadHistoryItemChange { + turn_id: "rollout-0".into(), + item: ThreadItem::WebSearch { + id: "search-1".into(), + query: "codex".into(), + action: Some(WebSearchAction::Search { + query: Some("codex".into()), + queries: None, + }), + }, + }], + changed_turns: vec![ThreadHistoryTurnChange { + turn_id: "rollout-0".into(), + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + removed_turn_ids: Vec::new(), + } + ); + } + + #[test] + fn changed_rollout_items_dedupe_turn_metadata_snapshots() { + let mut builder = ThreadHistoryBuilder::new(); + let changes = builder.handle_rollout_items_with_changes(&[ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + trace_id: None, + started_at: Some(10), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: Some(20), + duration_ms: Some(123), + time_to_first_token_ms: None, + })), + ]); + + assert_eq!( + changes, + ThreadHistoryChangeSet { + changed_items: Vec::new(), + changed_turns: vec![ThreadHistoryTurnChange { + turn_id: "turn-a".into(), + status: TurnStatus::Completed, + error: None, + started_at: Some(10), + completed_at: Some(20), + duration_ms: Some(123), + }], + removed_turn_ids: Vec::new(), + } + ); + } + + #[test] + fn changed_rollout_items_drop_prior_changes_for_removed_turns() { + let mut builder = ThreadHistoryBuilder::new(); + let changes = builder.handle_rollout_items_with_changes(&[ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + ..Default::default() + })), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + })), + ]); + + assert_eq!( + changes, + ThreadHistoryChangeSet { + changed_items: Vec::new(), + changed_turns: Vec::new(), + removed_turn_ids: vec!["turn-a".into()], + } + ); + } } diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f83674d4c37d..dccea51a3601 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -50,6 +50,9 @@ pub struct InitializeCapabilities { /// Opt into `attestation/generate` requests for upstream `x-oai-attestation`. #[serde(default)] pub request_attestation: bool, + /// Allow downstream MCP servers to request OpenAI extended form elicitations. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub mcp_server_openai_form_elicitation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] 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 3a29036974b9..2f8f838742e5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -1,5 +1,6 @@ use crate::protocol::common::AuthMode; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::account::AmazonBedrockCredentialSource; use codex_protocol::account::PlanType; use codex_protocol::account::ProviderAccount; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; @@ -24,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 = "chatgptPool", rename_all = "camelCase")] #[ts(rename = "chatgptPool", rename_all = "camelCase")] @@ -36,7 +41,20 @@ pub enum Account { #[serde(rename = "amazonBedrock", rename_all = "camelCase")] #[ts(rename = "amazonBedrock", rename_all = "camelCase")] - AmazonBedrock {}, + AmazonBedrock { + #[serde(default = "default_bedrock_credential_source")] + credential_source: AmazonBedrockCredentialSource, + }, +} + +fn nullable_string_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + generator.subschema_for::>() +} + +fn default_bedrock_credential_source() -> AmazonBedrockCredentialSource { + AmazonBedrockCredentialSource::AwsManaged } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -79,7 +97,9 @@ impl From for Account { }) .collect(), }, - ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, + ProviderAccount::AmazonBedrock { credential_source } => { + Self::AmazonBedrock { credential_source } + } } } } @@ -347,6 +367,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/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index c4d113d1c75b..b37f10dce778 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -177,6 +177,7 @@ pub struct AppsDefaultConfig { pub destructive_enabled: bool, #[serde(default = "default_enabled")] pub open_world_enabled: bool, + pub default_tools_approval_mode: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -650,7 +651,7 @@ pub struct ExternalAgentConfigDetectResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + /// If true, include detection under the user's home directory. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_home: bool, /// Zero or more working directories to include for repo-scoped detection. @@ -663,6 +664,9 @@ pub struct ExternalAgentConfigDetectParams { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportParams { pub migration_items: Vec, + /// Source product that produced the migration items. Missing means unspecified. + #[ts(optional = nullable)] + pub source: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -677,6 +681,7 @@ pub struct ExternalAgentConfigImportResponse { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportItemTypeFailure { pub item_type: ExternalAgentConfigMigrationItemType, + pub error_type: Option, pub failure_stage: String, pub message: String, pub cwd: Option, @@ -702,6 +707,31 @@ pub struct ExternalAgentConfigImportTypeResult { pub failures: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportHistory { + pub import_id: String, + pub completed_at_ms: i64, + pub successes: Vec, + pub failures: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportHistoriesReadResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportProgressNotification { + pub import_id: String, + pub item_type_results: Vec, +} + #[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/current_time.rs b/codex-rs/app-server-protocol/src/protocol/v2/current_time.rs new file mode 100644 index 000000000000..9fd23f798178 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/current_time.rs @@ -0,0 +1,20 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CurrentTimeReadParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CurrentTimeReadResponse { + /// Current time as whole Unix seconds. + #[ts(type = "number")] + pub current_time_at: i64, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/environment.rs b/codex-rs/app-server-protocol/src/protocol/v2/environment.rs index 294ae736fd76..ccffd5813bce 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/environment.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/environment.rs @@ -9,6 +9,10 @@ use ts_rs::TS; pub struct EnvironmentAddParams { pub environment_id: String, pub exec_server_url: String, + /// Optional WebSocket connection timeout. The server default applies when omitted. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub connect_timeout_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 502eb62e103a..e630de634b76 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -31,6 +31,7 @@ use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -256,7 +257,7 @@ pub enum ThreadItem { /// The command to be executed. command: String, /// The command's working directory. - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, /// Identifier for the underlying PTY process (when available). process_id: Option, #[serde(default)] @@ -289,8 +290,10 @@ pub enum ThreadItem { tool: String, status: McpToolCallStatus, arguments: JsonValue, + app_context: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + /// Deprecated: use `appContext.resourceUri` instead. mcp_app_resource_uri: Option, plugin_id: Option, result: Option>, @@ -383,6 +386,15 @@ pub enum ThreadItem { ContextCompaction { id: String }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct McpToolCallAppContext { + pub connector_id: String, + pub link_id: Option, + pub resource_uri: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -876,6 +888,11 @@ impl From for ThreadItem { tool: mcp.tool, status: McpToolCallStatus::from(mcp.status), arguments: mcp.arguments, + app_context: mcp.connector_id.map(|connector_id| McpToolCallAppContext { + connector_id, + link_id: mcp.link_id, + resource_uri: mcp.mcp_app_resource_uri.clone(), + }), mcp_app_resource_uri: mcp.mcp_app_resource_uri, plugin_id: mcp.plugin_id, result: mcp.result.map(McpToolCallResult::from).map(Box::new), @@ -1321,6 +1338,9 @@ pub struct CommandExecutionRequestApprovalParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub approval_id: Option, + /// Environment in which the command will run. + #[serde(default)] + pub environment_id: Option, /// Optional explanatory reason (e.g. request for network access). #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] @@ -1336,7 +1356,7 @@ pub struct CommandExecutionRequestApprovalParams { /// The command's working directory. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] - pub cwd: Option, + pub cwd: Option, /// Best-effort parsed command actions for friendly display. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index 37197a223768..cdfeb524f569 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -632,6 +632,15 @@ pub enum McpServerElicitationRequest { message: String, requested_schema: McpElicitationSchema, }, + #[serde(rename = "openai/form", rename_all = "camelCase")] + #[ts(rename = "openai/form", rename_all = "camelCase")] + OpenAiForm { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + requested_schema: JsonValue, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] Url { @@ -658,6 +667,15 @@ impl TryFrom for McpServerElicitationRequest { message, requested_schema: serde_json::from_value(requested_schema)?, }), + CoreElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + } => Ok(Self::OpenAiForm { + meta, + message, + requested_schema, + }), CoreElicitationRequest::Url { meta, message, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index b5fa9fdc6590..1097b038116e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -6,6 +6,7 @@ mod attestation; mod collaboration_mode; mod command_exec; mod config; +mod current_time; mod environment; mod experimental_feature; mod feedback; @@ -32,6 +33,7 @@ pub use attestation::*; pub use collaboration_mode::*; pub use command_exec::*; pub use config::*; +pub use current_time::*; pub use environment::*; pub use experimental_feature::*; pub use feedback::*; 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 93cb864aaaa5..6c1bc03ecd8d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -163,3 +163,14 @@ 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, +} 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 cc93e3a6c980..932e44e267ac 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -16,7 +16,7 @@ use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathConvention; use schemars::JsonSchema; use serde::Deserialize; @@ -57,9 +57,9 @@ impl From for NetworkApprovalContext { #[ts(export_to = "v2/")] pub struct AdditionalFileSystemPermissions { /// This will be removed in favor of `entries`. - pub read: Option>, + pub read: Option>, /// This will be removed in favor of `entries`. - pub write: Option>, + pub write: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub glob_scan_max_depth: Option, @@ -78,7 +78,7 @@ impl From> for AdditionalFileSystemPe if let Some(paths) = read.as_ref() { entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { path: FileSystemPath::Path { - path: ApiPathString::from_abs_path(path), + path: LegacyAppPathString::from_abs_path(path), }, access: FileSystemAccessMode::Read, })); @@ -86,14 +86,24 @@ impl From> for AdditionalFileSystemPe if let Some(paths) = write.as_ref() { entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { path: FileSystemPath::Path { - path: ApiPathString::from_abs_path(path), + path: LegacyAppPathString::from_abs_path(path), }, access: FileSystemAccessMode::Write, })); } Self { - read: read.map(|paths| paths.iter().map(ApiPathString::from_abs_path).collect()), - write: write.map(|paths| paths.iter().map(ApiPathString::from_abs_path).collect()), + read: read.map(|paths| { + paths + .iter() + .map(LegacyAppPathString::from_abs_path) + .collect() + }), + write: write.map(|paths| { + paths + .iter() + .map(LegacyAppPathString::from_abs_path) + .collect() + }), glob_scan_max_depth: None, entries: Some(entries), } @@ -276,7 +286,7 @@ impl From for CoreFileSystemSpecialPath { #[ts(export_to = "v2/")] // TODO(anp): Rename this type to distinguish it from the generic protocol FileSystemPath. pub enum FileSystemPath { - Path { path: ApiPathString }, + Path { path: LegacyAppPathString }, GlobPattern { pattern: String }, Special { value: FileSystemSpecialPath }, } @@ -286,7 +296,7 @@ impl From> for FileSystemPath { fn from(value: CoreFileSystemPath) -> Self { match value { CoreFileSystemPath::Path { path } => Self::Path { - path: ApiPathString::from_abs_path(&path), + path: LegacyAppPathString::from_abs_path(&path), }, CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, CoreFileSystemPath::Special { value } => Self::Special { @@ -368,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/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 793b316f53b7..4b8b2056ea0b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -1,6 +1,5 @@ use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RealtimeVoice; @@ -67,15 +66,21 @@ impl From for CoreRealtimeAudioFrame { #[ts(export_to = "v2/")] pub struct ThreadRealtimeStartParams { pub thread_id: String, - /// Overrides the configured realtime architecture for this session only. + /// Leaves Codex response handoffs to the client's explicit append calls instead of forwarding + /// them automatically. Defaults to false. #[ts(optional = nullable)] - pub architecture: Option, + pub client_managed_handoffs: Option, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. #[ts(optional = nullable)] pub codex_responses_as_items: Option, /// Optional prefix added to automatic Codex response items when `codexResponsesAsItems` is true. #[ts(optional = nullable)] pub codex_response_item_prefix: Option, + /// Optional prefix added to automatic V1 Codex commentary sent with + /// `conversation.handoff.append` when `codexResponsesAsItems` is not true. Final answers are + /// sent without the prefix. + #[ts(optional = nullable)] + pub codex_response_handoff_prefix: Option, /// Overrides the configured realtime model for this session only. #[ts(optional = nullable)] pub model: Option, 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 23f7a742781d..1a78f9592b21 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; @@ -35,7 +36,8 @@ use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; -use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; use serde_json::json; @@ -174,6 +176,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { model_provider: "openai".to_string(), created_at: 1, updated_at: 1, + recency_at: Some(1), status: ThreadStatus::Idle, path: None, cwd: absolute_path("tmp"), @@ -197,6 +200,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { sandbox: SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: Some(TurnsPage { data: Vec::new(), next_cursor: Some("cursor_next".to_string()), @@ -375,6 +379,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() { ..Default::default() }), }], + source: None, } ); } @@ -579,8 +584,8 @@ fn additional_file_system_permissions_populates_entries_for_legacy_roots() { ); let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - let read_only_api_path = ApiPathString::from_abs_path(&read_only_path); - let read_write_api_path = ApiPathString::from_abs_path(&read_write_path); + let read_only_api_path = LegacyAppPathString::from_abs_path(&read_only_path); + let read_write_api_path = LegacyAppPathString::from_abs_path(&read_write_path); assert_eq!( permissions, @@ -1942,6 +1947,40 @@ fn mcp_server_elicitation_request_from_core_form_request() { ); } +#[test] +fn mcp_server_elicitation_request_from_core_openai_form_request() { + let requested_schema = json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=", + }], + }, + }, + "required": ["template"], + }); + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report".to_string(), + requested_schema: requested_schema.clone(), + }) + .expect("OpenAI form request should convert"); + + assert_eq!( + request, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report".to_string(), + requested_schema, + } + ); +} + #[test] fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { let schema: McpElicitationSchema = serde_json::from_value(json!({ @@ -2622,7 +2661,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { server: "server".to_string(), tool: "tool".to_string(), arguments: json!({"arg": "value"}), + connector_id: Some("calendar".to_string()), mcp_app_resource_uri: Some("app://connector".to_string()), + link_id: Some("link_calendar".to_string()), plugin_id: Some("sample@test".to_string()), status: CoreMcpToolCallStatus::InProgress, result: None, @@ -2638,6 +2679,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { tool: "tool".to_string(), status: McpToolCallStatus::InProgress, arguments: json!({"arg": "value"}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("app://connector".to_string()), + }), mcp_app_resource_uri: Some("app://connector".to_string()), plugin_id: Some("sample@test".to_string()), result: None, @@ -2651,7 +2697,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { server: "server".to_string(), tool: "tool".to_string(), arguments: JsonValue::Null, + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, status: CoreMcpToolCallStatus::Completed, result: Some(CallToolResult { @@ -2672,6 +2720,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { tool: "tool".to_string(), status: McpToolCallStatus::Completed, arguments: JsonValue::Null, + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(McpToolCallResult { @@ -2685,6 +2734,49 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ); } +#[test] +fn mcp_tool_call_app_context_serializes_connector_id() { + let item = ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "codex_apps".to_string(), + tool: "calendar.create_event".to_string(), + status: McpToolCallStatus::InProgress, + arguments: json!({}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("app://connector".to_string()), + }), + mcp_app_resource_uri: Some("app://connector".to_string()), + plugin_id: None, + result: None, + error: None, + duration_ms: None, + }; + + assert_eq!( + serde_json::to_value(item).expect("MCP tool call should serialize"), + json!({ + "type": "mcpToolCall", + "id": "mcp-1", + "server": "codex_apps", + "tool": "calendar.create_event", + "status": "inProgress", + "arguments": {}, + "appContext": { + "connectorId": "calendar", + "linkId": "link_calendar", + "resourceUri": "app://connector", + }, + "mcpAppResourceUri": "app://connector", + "pluginId": null, + "result": null, + "error": null, + "durationMs": null, + }) + ); +} + #[test] fn user_input_into_core_preserves_image_detail() { assert_eq!( @@ -3639,16 +3731,69 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { serde_json::from_value(response.clone()).expect("thread/start response"); let resume: ThreadResumeResponse = serde_json::from_value(response.clone()).expect("thread/resume response"); - let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); + let fork: ThreadForkResponse = + serde_json::from_value(response.clone()).expect("thread/fork response"); - assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(start.thread.parent_thread_id, None); - assert_eq!(resume.instruction_sources, Vec::::new()); - assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!(start.thread.recency_at, None); + assert_eq!( + resume.instruction_sources, + Vec::::new() + ); + assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); + assert_eq!( + ( + start.multi_agent_mode, + resume.multi_agent_mode, + fork.multi_agent_mode, + ), + ( + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + ) + ); + + let foreign_source: LegacyAppPathString = + serde_json::from_value(json!(r"C:\workspace\AGENTS.md")).expect("foreign source"); + let mut response_with_foreign_source = response; + response_with_foreign_source["instructionSources"] = json!([foreign_source.as_str()]); + let start: ThreadStartResponse = serde_json::from_value(response_with_foreign_source.clone()) + .expect("thread/start response with foreign source"); + let resume: ThreadResumeResponse = serde_json::from_value(response_with_foreign_source.clone()) + .expect("thread/resume response with foreign source"); + let fork: ThreadForkResponse = serde_json::from_value(response_with_foreign_source) + .expect("thread/fork response with foreign source"); + assert_eq!(start.instruction_sources, vec![foreign_source.clone()]); + assert_eq!(resume.instruction_sources, vec![foreign_source.clone()]); + assert_eq!(fork.instruction_sources, vec![foreign_source]); + let foreign_source_uri = + PathUri::parse("file:///C:/workspace/AGENTS.md").expect("foreign source URI"); + assert_eq!( + start.instruction_source_path_uris(), + vec![foreign_source_uri.clone()] + ); + assert_eq!( + resume.instruction_source_path_uris(), + vec![foreign_source_uri.clone()] + ); + assert_eq!( + fork.instruction_source_path_uris(), + vec![foreign_source_uri] + ); +} + +#[test] +fn thread_recency_sort_key_serializes_as_snake_case() { + assert_eq!( + serde_json::to_value(ThreadSortKey::RecencyAt).expect("sort key should serialize"), + json!("recency_at") + ); } #[test] @@ -3686,6 +3831,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { summary: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, personality: None, }; let serialized_without_override = @@ -3693,6 +3839,50 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn turn_start_params_round_trip_multi_agent_mode() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "multiAgentMode": "proactive" + })) + .expect("params should deserialize"); + + assert_eq!( + params.multi_agent_mode, + Some(codex_protocol::config_types::MultiAgentMode::Proactive) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.multiAgentMode") + ); + assert_eq!( + serde_json::to_value(params).expect("params should serialize")["multiAgentMode"], + "proactive" + ); +} + +#[test] +fn thread_start_params_round_trip_multi_agent_mode() { + let params: ThreadStartParams = serde_json::from_value(json!({ + "multiAgentMode": "proactive" + })) + .expect("params should deserialize"); + + assert_eq!( + params.multi_agent_mode, + Some(codex_protocol::config_types::MultiAgentMode::Proactive) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("thread/start.multiAgentMode") + ); + assert_eq!( + serde_json::to_value(params).expect("params should serialize")["multiAgentMode"], + "proactive" + ); +} + #[test] fn thread_settings_update_params_preserve_explicit_null_service_tier() { let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ @@ -3766,7 +3956,14 @@ fn thread_settings_update_params_preserve_field_level_experimental_gates() { #[test] fn turn_start_params_round_trip_environments() { - let cwd = test_absolute_path(); + // Use a path foreign to the test host so this exercises syntax preservation instead of the + // host-native conversion performed by test_absolute_path(). + #[cfg(windows)] + let raw_cwd = "/workspace"; + #[cfg(not(windows))] + let raw_cwd = r"C:\workspace"; + let cwd: LegacyAppPathString = + serde_json::from_value(json!(raw_cwd)).expect("API path should deserialize"); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], @@ -3848,27 +4045,6 @@ fn turn_start_params_treat_null_or_omitted_environments_as_default() { ); } -#[test] -fn turn_start_params_reject_relative_environment_cwd() { - let err = serde_json::from_value::(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": "relative" - } - ], - })) - .expect_err("relative environment cwd should fail"); - - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); -} - #[test] fn realtime_append_text_defaults_role_to_user() { let params = serde_json::from_value::(json!({ 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 4721b91f4e66..24da83bf054b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -14,6 +14,7 @@ use codex_experimental_api_macros::ExperimentalApi; pub use codex_protocol::capabilities::CapabilityRootLocation; pub use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; pub use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; @@ -26,6 +27,8 @@ use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -91,6 +94,12 @@ pub struct ThreadStartParams { pub developer_instructions: Option, #[ts(optional = nullable)] pub personality: Option, + /// 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, #[ts(optional = nullable)] pub ephemeral: Option, #[ts(optional = nullable)] @@ -161,9 +170,9 @@ pub struct ThreadStartResponse { #[experimental("thread/start.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -177,6 +186,17 @@ pub struct ThreadStartResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current multi-agent mode for this thread. + #[experimental("thread/start.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: MultiAgentMode, +} + +impl ThreadStartResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } } #[derive( @@ -230,6 +250,10 @@ pub struct ThreadSettingsUpdateParams { #[experimental("thread/settings/update.collaborationMode")] #[ts(optional = nullable)] pub collaboration_mode: Option, + /// Select the multi-agent mode for subsequent turns. + #[experimental("thread/settings/update.multiAgentMode")] + #[ts(optional = nullable)] + pub multi_agent_mode: Option, /// Override the personality for subsequent turns. #[ts(optional = nullable)] pub personality: Option, @@ -240,7 +264,7 @@ pub struct ThreadSettingsUpdateParams { #[ts(export_to = "v2/")] pub struct ThreadSettingsUpdateResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadSettings { @@ -255,6 +279,10 @@ pub struct ThreadSettings { pub effort: Option, pub summary: Option, pub collaboration_mode: CollaborationMode, + /// Current multi-agent mode for this thread. + #[experimental("thread/settings.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: MultiAgentMode, pub personality: Option, } @@ -375,9 +403,9 @@ pub struct ThreadResumeResponse { #[experimental("thread/resume.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -391,12 +419,23 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current multi-agent mode for this thread. + #[experimental("thread/resume.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: MultiAgentMode, /// `thread/turns/list` page returned when requested by `initialTurnsPage`. #[experimental("thread/resume.initialTurnsPage")] #[serde(default)] pub initial_turns_page: Option, } +impl ThreadResumeResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -523,9 +562,9 @@ pub struct ThreadForkResponse { #[experimental("thread/fork.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -539,6 +578,34 @@ pub struct ThreadForkResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current multi-agent mode for this thread. + #[experimental("thread/fork.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: MultiAgentMode, +} + +impl ThreadForkResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + +fn instruction_source_path_uris(sources: &[LegacyAppPathString]) -> Vec { + // Instruction sources are advisory diagnostics. Warn and fail open so a malformed legacy + // path cannot fail thread start, resume, or fork. + sources + .iter() + .filter_map(|source| { + source.to_inferred_path_uri().or_else(|| { + tracing::warn!( + path = source.as_str(), + "ignoring invalid instruction source path from app-server" + ); + None + }) + }) + .collect() } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1102,6 +1169,7 @@ pub enum ThreadSourceKind { pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index fc0762361ff4..52a8d7017a51 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -152,6 +152,9 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was last updated. #[ts(type = "number")] pub updated_at: i64, + /// Unix timestamp (in seconds) used for thread recency ordering. + #[ts(type = "number | null")] + pub recency_at: Option, /// Current runtime status for the thread. pub status: ThreadStatus, /// [UNSTABLE] Path to the thread on disk. 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 836c6d4e9693..1fc35d15152c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -4,6 +4,7 @@ use super::SandboxPolicy; use super::Turn; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ImageDetail; @@ -14,6 +15,7 @@ use codex_protocol::user_input::ByteRange as CoreByteRange; use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -38,7 +40,7 @@ pub enum TurnStatus { #[ts(export_to = "v2/")] pub struct TurnEnvironmentParams { pub environment_id: String, - pub cwd: AbsolutePathBuf, + pub cwd: LegacyAppPathString, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] @@ -148,6 +150,13 @@ pub struct TurnStartParams { #[experimental("turn/start.collaborationMode")] #[ts(optional = nullable)] pub collaboration_mode: Option, + + /// 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, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/Cargo.toml b/codex-rs/app-server-test-client/Cargo.toml index 603a5caf22d7..901afb70d00f 100644 --- a/codex-rs/app-server-test-client/Cargo.toml +++ b/codex-rs/app-server-test-client/Cargo.toml @@ -25,5 +25,7 @@ url = { workspace = true } uuid = { workspace = true, features = ["v4"] } [lib] -test = false 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 9a553913275b..44687672a010 100644 --- a/codex-rs/app-server-test-client/README.md +++ b/codex-rs/app-server-test-client/README.md @@ -18,6 +18,93 @@ 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 +enable/disable config writes, and a structured plugin mention through one +app-server connection. Analytics are captured to a local JSONL file and are +not sent to the analytics backend. The model turn uses a loopback Responses +API server. + +The selected plugin must already be installed and enabled remotely, and the +active Codex profile must be authenticated. On a fresh local cache, the command +retries ephemeral turns while the installed remote bundle finishes syncing. + +```bash +# Build a debug Codex binary; analytics capture is unavailable in release builds. +cargo build -p codex-cli --bin codex + +cargo run -p codex-app-server-test-client -- \ + --codex-bin ./target/debug/codex \ + plugin-analytics-smoke \ + --plugin-id linear@openai-curated-remote +``` + +Use `--capture-file /tmp/plugin-analytics.jsonl` to select the output path. +The command validates one `codex_plugin_disabled`, `codex_plugin_enabled`, and +`codex_plugin_used` event with the expected local plugin identity and capability +metadata. The enabled and disabled events come from successful writes to the +temporary config; the command does not mutate the remote enabled state. It +prints the events and leaves the JSONL file in place for inspection. It does not +install or uninstall plugins and does not modify the profile's persistent +config. + +### Testing remote install and uninstall analytics + +`plugin-analytics-mutation-smoke` is a manually invoked live smoke test. It +contacts the configured remote plugin API and temporarily changes the active +account's installed-plugin state. It is not run by `cargo test`, `just test`, +or CI. + +Choose a remote plugin that is available to the active account and is not +currently installed. The command refuses to run when the plugin is already +installed, installs it, validates `codex_plugin_installed`, uninstalls it, and +verifies that the original uninstalled state was restored. The current install +event uses the backend ID as `plugin_id`. Uninstall is part of cleanup but is +not yet an analytics assertion. + +`--remote-plugin-id` takes the backend ID, such as `plugins~Plugin_...`, not the +local `@` ID. + +```bash +cargo run -p codex-app-server-test-client -- \ + --codex-bin ./target/debug/codex \ + plugin-analytics-mutation-smoke \ + --remote-plugin-id \ + --confirm-account-mutation \ + --capture-file /tmp/plugin-mutation-analytics.jsonl +``` + +Analytics use the normal queue, reduction, batching, and serialization path, +but the debug capture destination suppresses analytics network delivery. The +command prints one of these final states: + +- `PASS`: the install event validated and the plugin is uninstalled. +- `FAIL-CLEAN`: validation failed, but the original uninstalled state was + restored. +- `FAIL-LOCAL-CACHE`: the backend is uninstalled, but local cleanup reported + an error. +- `FAIL-DIRTY`: cleanup failed and the plugin still appears installed. +- `FAIL-UNKNOWN`: the command could not verify the final installed state. + +For a dirty or uncertain result, retry cleanup with: + +```bash +cargo run -p codex-app-server-test-client -- \ + --codex-bin ./target/debug/codex \ + plugin-remote-uninstall \ + --remote-plugin-id \ + --confirm-account-mutation +``` + +Cleanup does not require analytics capture or a debug Codex binary. When the +smoke uses global `--config` overrides, its printed recovery command preserves +them so cleanup targets the same backend and account. + ## Watching Raw Inbound Traffic Initialize a connection, then print every inbound JSON-RPC message until you stop it with diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index ebe49758d363..8e4630857543 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -87,6 +87,12 @@ use tungstenite::stream::MaybeTlsStream; use url::Url; use uuid::Uuid; +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. "command/exec/outputDelta", @@ -272,6 +278,39 @@ enum CliCommand { #[arg(long, default_value_t = 15)] hold_seconds: u64, }, + /// Exercise remote plugin analytics through production app-server RPC paths. + #[command(name = "plugin-analytics-smoke")] + PluginAnalyticsSmoke { + /// Installed local plugin id, such as `linear@openai-curated-remote`. + #[arg(long)] + plugin_id: String, + /// JSONL output path. Defaults to a PID-specific file under the system temp directory. + #[arg(long)] + capture_file: Option, + }, + /// Install and uninstall one remote plugin while validating analytics capture. + #[command(name = "plugin-analytics-mutation-smoke")] + PluginAnalyticsMutationSmoke { + /// Backend remote plugin id. The plugin must be initially uninstalled. + #[arg(long)] + remote_plugin_id: String, + /// Acknowledge that this command mutates the active account's plugin state. + #[arg(long)] + confirm_account_mutation: bool, + /// JSONL output path. Defaults to a PID-specific file under the system temp directory. + #[arg(long)] + capture_file: Option, + }, + /// Best-effort recovery command that uninstalls one remote plugin. + #[command(name = "plugin-remote-uninstall")] + PluginRemoteUninstall { + /// Backend remote plugin id to uninstall. + #[arg(long)] + remote_plugin_id: String, + /// Acknowledge that this command mutates the active account's plugin state. + #[arg(long)] + confirm_account_mutation: bool, + }, } pub async fn run() -> Result<()> { @@ -423,6 +462,58 @@ pub async fn run() -> Result<()> { hold_seconds, ) } + CliCommand::PluginAnalyticsSmoke { + plugin_id, + capture_file, + } => { + ensure_dynamic_tools_unused(&dynamic_tools, "plugin-analytics-smoke")?; + if url.is_some() { + bail!("plugin-analytics-smoke requires --codex-bin and does not support --url"); + } + let codex_bin = codex_bin.context("plugin-analytics-smoke requires --codex-bin")?; + plugin_analytics_smoke::run(&codex_bin, &config_overrides, &plugin_id, capture_file) + } + CliCommand::PluginAnalyticsMutationSmoke { + remote_plugin_id, + confirm_account_mutation, + capture_file, + } => { + ensure_dynamic_tools_unused(&dynamic_tools, "plugin-analytics-mutation-smoke")?; + if url.is_some() { + bail!( + "plugin-analytics-mutation-smoke requires --codex-bin and does not support --url" + ); + } + let codex_bin = + codex_bin.context("plugin-analytics-mutation-smoke requires --codex-bin")?; + plugin_analytics_mutation_smoke::run( + &codex_bin, + &config_overrides, + &remote_plugin_id, + plugin_analytics_mutation_smoke::AccountMutationConfirmation::from_flag( + confirm_account_mutation, + ), + capture_file, + ) + } + CliCommand::PluginRemoteUninstall { + remote_plugin_id, + confirm_account_mutation, + } => { + ensure_dynamic_tools_unused(&dynamic_tools, "plugin-remote-uninstall")?; + if url.is_some() { + bail!("plugin-remote-uninstall requires --codex-bin and does not support --url"); + } + let codex_bin = codex_bin.context("plugin-remote-uninstall requires --codex-bin")?; + plugin_analytics_mutation_smoke::run_cleanup( + &codex_bin, + &config_overrides, + &remote_plugin_id, + plugin_analytics_mutation_smoke::AccountMutationConfirmation::from_flag( + confirm_account_mutation, + ), + ) + } } } @@ -1440,6 +1531,14 @@ impl CodexClient { } fn spawn_stdio(codex_bin: &Path, config_overrides: &[String]) -> Result { + Self::spawn_stdio_with_env(codex_bin, config_overrides, &[]) + } + + fn spawn_stdio_with_env( + codex_bin: &Path, + config_overrides: &[String], + environment: &[(OsString, OsString)], + ) -> Result { let codex_bin_display = codex_bin.display(); let mut cmd = Command::new(codex_bin); if let Some(codex_bin_parent) = codex_bin.parent() { @@ -1453,6 +1552,9 @@ impl CodexClient { for override_kv in config_overrides { cmd.arg("--config").arg(override_kv); } + for (name, value) in environment { + cmd.env(name, value); + } let mut codex_app_server = cmd .arg("app-server") .stdin(Stdio::piped()) @@ -1567,6 +1669,7 @@ impl CodexClient { .map(|method| (*method).to_string()) .collect(), ), + mcp_server_openai_form_elicitation: false, }), }, }; @@ -1938,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:?}"); } @@ -1957,6 +2064,7 @@ impl CodexClient { item_id, started_at_ms: _, approval_id, + environment_id, reason, network_approval_context, command, @@ -1974,6 +2082,9 @@ impl CodexClient { ); self.command_approval_count += 1; self.command_approval_item_ids.push(item_id.clone()); + if let Some(environment_id) = environment_id.as_deref() { + println!("< environment: {environment_id}"); + } if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } @@ -1987,7 +2098,7 @@ impl CodexClient { println!("< command: {command}"); } if let Some(cwd) = cwd.as_ref() { - println!("< cwd: {}", cwd.display()); + println!("< cwd: {cwd}"); } if let Some(command_actions) = command_actions.as_ref() && !command_actions.is_empty() diff --git a/codex-rs/app-server-test-client/src/loopback_responses_server.rs b/codex-rs/app-server-test-client/src/loopback_responses_server.rs new file mode 100644 index 000000000000..74b7c753e003 --- /dev/null +++ b/codex-rs/app-server-test-client/src/loopback_responses_server.rs @@ -0,0 +1,145 @@ +use anyhow::Context; +use anyhow::Result; +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::TcpListener; +use std::net::TcpStream; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::thread::JoinHandle; +use std::time::Duration; + +pub(super) struct LoopbackResponsesServer { + base_url: String, + shutdown: Arc, + thread: Option>, +} + +impl LoopbackResponsesServer { + pub(super) fn start() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").context("bind loopback Responses API server")?; + listener + .set_nonblocking(true) + .context("set loopback Responses API server nonblocking")?; + let address = listener.local_addr()?; + let shutdown = Arc::new(AtomicBool::new(false)); + let thread_shutdown = Arc::clone(&shutdown); + let thread = thread::spawn(move || { + while !thread_shutdown.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + if let Err(err) = handle_model_connection(stream) { + eprintln!("loopback Responses API server error: {err}"); + } + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => { + eprintln!("loopback Responses API accept error: {err}"); + break; + } + } + } + }); + Ok(Self { + base_url: format!("http://{address}"), + shutdown, + thread: Some(thread), + }) + } + + pub(super) fn base_url(&self) -> &str { + &self.base_url + } +} + +impl Drop for LoopbackResponsesServer { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn handle_model_connection(mut stream: TcpStream) -> io::Result<()> { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let request = read_http_request(&mut stream)?; + let request_line = request + .split(|byte| *byte == b'\n') + .next() + .and_then(|line| std::str::from_utf8(line).ok()) + .unwrap_or_default(); + if request_line.starts_with("POST ") && request_line.contains("/responses ") { + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-plugin-analytics\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-plugin-analytics\",\"usage\":{\"input_tokens\":0,\"input_tokens_details\":null,\"output_tokens\":0,\"output_tokens_details\":null,\"total_tokens\":0}}}\n\n" + ); + write_http_response(&mut stream, "200 OK", "text/event-stream", body) + } else { + write_http_response( + &mut stream, + "404 Not Found", + "application/json", + r#"{"error":"not found"}"#, + ) + } +} + +fn read_http_request(stream: &mut TcpStream) -> io::Result> { + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut buffer)?; + if read == 0 { + return Ok(request); + } + request.extend_from_slice(&buffer[..read]); + if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let content_length = parse_content_length(&request[..header_end]); + while request.len() < header_end + content_length { + let read = stream.read(&mut buffer)?; + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + Ok(request) +} + +fn parse_content_length(headers: &[u8]) -> usize { + String::from_utf8_lossy(headers) + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().ok()) + .flatten() + }) + .unwrap_or(0) +} + +fn write_http_response( + stream: &mut TcpStream, + status: &str, + content_type: &str, + body: &str, +) -> io::Result<()> { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + stream.flush() +} diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs b/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs new file mode 100644 index 000000000000..4ffd3fd23d27 --- /dev/null +++ b/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs @@ -0,0 +1,102 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde_json::Value; +use std::fs; +use std::io; +use std::path::Path; + +pub(super) fn read_events_for_remote_plugin( + path: &Path, + remote_plugin_id: &str, +) -> Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => { + return Err(err).with_context(|| format!("read capture file {}", path.display())); + } + }; + let mut matching = Vec::new(); + for (index, line) in contents.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let payload: Value = serde_json::from_str(line).with_context(|| { + format!( + "parse analytics capture line {} from {}", + index + 1, + path.display() + ) + })?; + let events = payload["events"] + .as_array() + .context("analytics capture payload is missing events")?; + matching.extend( + events + .iter() + .filter(|event| event["event_params"]["plugin_id"] == remote_plugin_id) + .cloned(), + ); + } + Ok(matching) +} + +pub(super) struct PluginEventIdentity<'a> { + pub(super) plugin_id: &'a str, + pub(super) plugin_name: &'a str, + pub(super) marketplace_name: &'a str, +} + +pub(super) fn validate_mutation_events( + events: Vec, + expected: PluginEventIdentity<'_>, +) -> Result> { + let event_type = "codex_plugin_installed"; + let matching = events + .iter() + .filter(|event| event["event_type"] == event_type) + .collect::>(); + let [event] = matching.as_slice() else { + bail!( + "expected exactly one `{event_type}` event for `{}`, found {}", + expected.plugin_id, + matching.len() + ); + }; + validate_event(event, &expected)?; + Ok(vec![(*event).clone()]) +} + +fn validate_event(event: &Value, expected: &PluginEventIdentity<'_>) -> Result<()> { + let params = &event["event_params"]; + require_string(params, "plugin_id", expected.plugin_id)?; + require_string(params, "plugin_name", expected.plugin_name)?; + require_string(params, "marketplace_name", expected.marketplace_name)?; + for field in [ + "has_skills", + "mcp_server_count", + "connector_ids", + "product_client_id", + ] { + if params.get(field).is_none_or(Value::is_null) { + bail!( + "{} event has null or missing `{field}`", + event["event_type"] + ); + } + } + Ok(()) +} + +fn require_string(params: &Value, field: &str, expected: &str) -> Result<()> { + let actual = params.get(field).and_then(Value::as_str); + if actual != Some(expected) { + bail!("expected `{field}` to be `{expected}`, got {actual:?}"); + } + Ok(()) +} + +#[cfg(test)] +#[path = "plugin_analytics_capture_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_capture_tests.rs b/codex-rs/app-server-test-client/src/plugin_analytics_capture_tests.rs new file mode 100644 index 000000000000..45a9c865b8f5 --- /dev/null +++ b/codex-rs/app-server-test-client/src/plugin_analytics_capture_tests.rs @@ -0,0 +1,93 @@ +use super::PluginEventIdentity; +use super::read_events_for_remote_plugin; +use super::validate_mutation_events; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::path::PathBuf; +use std::process; +use std::time::SystemTime; + +const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_test"; + +#[test] +fn reads_and_validates_remote_plugin_mutation_events() { + let path = unique_capture_path("valid"); + let installed = mutation_event("codex_plugin_installed"); + let unrelated = json!({ + "event_type": "codex_plugin_installed", + "event_params": { + "plugin_id": "plugins~Plugin_other" + } + }); + let contents = [ + json!({"events": [unrelated]}), + json!({"events": [installed]}), + ] + .into_iter() + .map(|payload| serde_json::to_string(&payload).expect("serialize capture payload")) + .collect::>() + .join("\n"); + fs::write(&path, contents).expect("write capture file"); + + let events = read_events_for_remote_plugin(&path, REMOTE_PLUGIN_ID) + .expect("read matching plugin events"); + let validated = + validate_mutation_events(events, expected_identity()).expect("validate mutation events"); + + assert_eq!(validated, vec![installed]); + fs::remove_file(path).expect("remove capture file"); +} + +#[test] +fn rejects_duplicate_mutation_events() { + let installed = mutation_event("codex_plugin_installed"); + let error = validate_mutation_events(vec![installed.clone(), installed], expected_identity()) + .expect_err("duplicate install events should fail validation"); + + assert!(error.to_string().contains("found 2")); +} + +#[test] +fn rejects_missing_capability_metadata() { + let mut installed = mutation_event("codex_plugin_installed"); + installed["event_params"]["has_skills"] = Value::Null; + let error = validate_mutation_events(vec![installed], expected_identity()) + .expect_err("missing capability metadata should fail validation"); + + assert!(error.to_string().contains("has_skills")); +} + +fn mutation_event(event_type: &str) -> Value { + json!({ + "event_type": event_type, + "event_params": { + "plugin_id": REMOTE_PLUGIN_ID, + "plugin_name": "sample", + "marketplace_name": "openai-curated-remote", + "has_skills": true, + "mcp_server_count": 0, + "connector_ids": [], + "product_client_id": "test-client" + } + }) +} + +fn expected_identity() -> PluginEventIdentity<'static> { + PluginEventIdentity { + plugin_id: REMOTE_PLUGIN_ID, + plugin_name: "sample", + marketplace_name: "openai-curated-remote", + } +} + +fn unique_capture_path(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "codex-plugin-analytics-capture-{name}-{}-{nonce}.jsonl", + process::id() + )) +} diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_mutation_smoke.rs b/codex-rs/app-server-test-client/src/plugin_analytics_mutation_smoke.rs new file mode 100644 index 000000000000..2c6d50541221 --- /dev/null +++ b/codex-rs/app-server-test-client/src/plugin_analytics_mutation_smoke.rs @@ -0,0 +1,483 @@ +use super::CodexClient; +use super::plugin_analytics_capture::PluginEventIdentity; +use super::plugin_analytics_capture::read_events_for_remote_plugin; +use super::plugin_analytics_capture::validate_mutation_events; +use super::plugin_analytics_smoke::ANALYTICS_CAPTURE_ENV_VAR; +use super::plugin_analytics_smoke::prepare_capture_file; +use super::plugin_analytics_smoke::wait_until_capture_is_ready; +use super::shell_quote; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use serde_json::Value; +use std::ffi::OsString; +use std::path::Path; +use std::path::PathBuf; +use std::process; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +const REMOTE_MARKETPLACE_HINT: &str = "openai-curated-remote"; +const STATE_TIMEOUT: Duration = Duration::from_secs(15); +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub(super) fn run( + codex_bin: &Path, + config_overrides: &[String], + remote_plugin_id: &str, + confirmation: AccountMutationConfirmation, + capture_file: Option, +) -> Result<()> { + require_confirmation(confirmation)?; + let capture_path = capture_file.unwrap_or_else(|| { + std::env::temp_dir().join(format!( + "codex-plugin-analytics-mutation-{}.jsonl", + process::id() + )) + }); + prepare_capture_file(&capture_path)?; + let mut client = spawn_client(codex_bin, config_overrides, &capture_path)?; + wait_until_capture_is_ready(&capture_path)?; + client.initialize()?; + + let initial = read_remote_plugin(&mut client, remote_plugin_id)?; + validate_initial_plugin(&initial, remote_plugin_id)?; + println!( + "remote plugin mutation smoke: local_id={} remote_id={} marketplace={}", + initial.plugin_id, initial.remote_plugin_id, initial.marketplace_name + ); + + let MutationSequenceResult { + result: sequence_result, + uninstall_rpc_failed, + } = run_mutation_sequence(&mut client, &capture_path, &initial); + let restoration = restore_uninstalled_state(&mut client, remote_plugin_id); + println!("capture file: {}", capture_path.display()); + + match (sequence_result, restoration) { + (Ok(events), RestorationStatus::Clean) => { + println!( + "\n[plugin analytics mutation smoke validated]\n{}", + serde_json::to_string_pretty(&events)? + ); + println!("PASS: analytics validated; original uninstalled state restored"); + Ok(()) + } + (Err(err), RestorationStatus::Clean) if uninstall_rpc_failed => { + eprintln!( + "FAIL-LOCAL-CACHE: backend state is uninstalled, but the uninstall RPC failed after the backend mutation: {err:#}" + ); + Err(err) + } + (Err(err), RestorationStatus::Clean) => { + eprintln!("FAIL-CLEAN: {err:#}"); + eprintln!("The original uninstalled account state was restored."); + Err(err) + } + (sequence_result, RestorationStatus::LocalCleanupFailure(cleanup_err)) => { + let sequence_err = sequence_result.err(); + eprintln!( + "FAIL-LOCAL-CACHE: backend state is uninstalled, but local cleanup reported an error: {cleanup_err:#}" + ); + Err(sequence_err.unwrap_or(cleanup_err)) + } + (sequence_result, RestorationStatus::Dirty(cleanup_err)) => { + if let Err(err) = sequence_result { + eprintln!("mutation smoke failed before cleanup: {err:#}"); + } + print_dirty_recovery(codex_bin, config_overrides, remote_plugin_id, &cleanup_err); + Err(cleanup_err) + } + (sequence_result, RestorationStatus::Unknown(cleanup_err)) => { + if let Err(err) = sequence_result { + eprintln!("mutation smoke failed before final state verification: {err:#}"); + } + eprintln!( + "FAIL-UNKNOWN: could not verify whether `{remote_plugin_id}` is installed: {cleanup_err:#}" + ); + print_recovery_command(codex_bin, config_overrides, remote_plugin_id); + Err(cleanup_err) + } + } +} + +pub(super) fn run_cleanup( + codex_bin: &Path, + config_overrides: &[String], + remote_plugin_id: &str, + confirmation: AccountMutationConfirmation, +) -> Result<()> { + require_confirmation(confirmation)?; + let mut overrides = config_overrides.to_vec(); + overrides.extend([ + "analytics.enabled=false".to_string(), + "features.plugins=true".to_string(), + "features.remote_plugin=true".to_string(), + ]); + let mut client = CodexClient::spawn_stdio(codex_bin, &overrides)?; + client.initialize()?; + + match restore_uninstalled_state(&mut client, remote_plugin_id) { + RestorationStatus::Clean => { + println!("PASS: `{remote_plugin_id}` is uninstalled"); + Ok(()) + } + RestorationStatus::LocalCleanupFailure(err) => { + eprintln!( + "FAIL-LOCAL-CACHE: backend state is uninstalled, but local cleanup reported an error: {err:#}" + ); + Err(err) + } + RestorationStatus::Dirty(err) => { + print_dirty_recovery(codex_bin, config_overrides, remote_plugin_id, &err); + Err(err) + } + RestorationStatus::Unknown(err) => { + eprintln!( + "FAIL-UNKNOWN: could not verify whether `{remote_plugin_id}` is installed: {err:#}" + ); + Err(err) + } + } +} + +#[derive(Clone, Copy)] +pub(super) enum AccountMutationConfirmation { + Confirmed, + Missing, +} + +impl AccountMutationConfirmation { + pub(super) fn from_flag(confirm_account_mutation: bool) -> Self { + if confirm_account_mutation { + Self::Confirmed + } else { + Self::Missing + } + } +} + +fn require_confirmation(confirmation: AccountMutationConfirmation) -> Result<()> { + if matches!(confirmation, AccountMutationConfirmation::Missing) { + bail!( + "this command installs and uninstalls a plugin on the active account; rerun with --confirm-account-mutation" + ); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug)] +enum ExpectedInstalledState { + Installed, + Uninstalled, +} + +impl ExpectedInstalledState { + fn is_installed(self) -> bool { + matches!(self, Self::Installed) + } +} + +fn spawn_client( + codex_bin: &Path, + config_overrides: &[String], + capture_path: &Path, +) -> Result { + let mut overrides = config_overrides.to_vec(); + overrides.extend([ + "analytics.enabled=true".to_string(), + "features.plugins=true".to_string(), + "features.remote_plugin=true".to_string(), + ]); + let environment = vec![( + OsString::from(ANALYTICS_CAPTURE_ENV_VAR), + capture_path.as_os_str().to_os_string(), + )]; + CodexClient::spawn_stdio_with_env(codex_bin, &overrides, &environment) +} + +#[derive(Clone, Debug)] +struct RemotePluginExpectation { + plugin_id: String, + remote_plugin_id: String, + plugin_name: String, + marketplace_name: String, + installed: bool, + install_policy: PluginInstallPolicy, + availability: PluginAvailability, +} + +fn read_remote_plugin( + client: &mut CodexClient, + remote_plugin_id: &str, +) -> Result { + let request_id = client.request_id(); + let response: PluginReadResponse = client.send_request( + ClientRequest::PluginRead { + request_id: request_id.clone(), + params: PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some(REMOTE_MARKETPLACE_HINT.to_string()), + plugin_name: remote_plugin_id.to_string(), + }, + }, + request_id, + "plugin/read", + )?; + let summary = response.plugin.summary; + let actual_remote_plugin_id = summary + .remote_plugin_id + .with_context(|| format!("plugin/read returned no remote id for `{remote_plugin_id}`"))?; + if actual_remote_plugin_id != remote_plugin_id { + bail!( + "plugin/read returned remote id `{actual_remote_plugin_id}` for requested id `{remote_plugin_id}`" + ); + } + Ok(RemotePluginExpectation { + plugin_id: summary.id, + remote_plugin_id: actual_remote_plugin_id, + plugin_name: summary.name, + marketplace_name: response.plugin.marketplace_name, + installed: summary.installed, + install_policy: summary.install_policy, + availability: summary.availability, + }) +} + +fn validate_initial_plugin(plugin: &RemotePluginExpectation, remote_plugin_id: &str) -> Result<()> { + if plugin.installed { + bail!( + "refusing to run: remote plugin `{remote_plugin_id}` is already installed; choose an initially uninstalled plugin" + ); + } + if plugin.availability != PluginAvailability::Available { + bail!( + "remote plugin `{remote_plugin_id}` is not available: {:?}", + plugin.availability + ); + } + if plugin.install_policy == PluginInstallPolicy::NotAvailable { + bail!("remote plugin `{remote_plugin_id}` is not available for install"); + } + Ok(()) +} + +struct MutationSequenceResult { + result: Result>, + uninstall_rpc_failed: bool, +} + +fn run_mutation_sequence( + client: &mut CodexClient, + capture_path: &Path, + expected: &RemotePluginExpectation, +) -> MutationSequenceResult { + let mut uninstall_rpc_failed = false; + let result = (|| { + install_remote_plugin(client, expected)?; + wait_for_installed_state( + client, + &expected.remote_plugin_id, + ExpectedInstalledState::Installed, + )?; + wait_for_remote_plugin_event( + capture_path, + &expected.remote_plugin_id, + "codex_plugin_installed", + )?; + + let uninstall_error = uninstall_remote_plugin(client, &expected.remote_plugin_id).err(); + uninstall_rpc_failed = uninstall_error.is_some(); + wait_for_installed_state( + client, + &expected.remote_plugin_id, + ExpectedInstalledState::Uninstalled, + ) + .map_err(|state_err| { + if let Some(err) = uninstall_error.as_ref() { + anyhow!("plugin/uninstall failed: {err:#}; final state check failed: {state_err:#}") + } else { + state_err + } + })?; + + let captured_events = + read_events_for_remote_plugin(capture_path, &expected.remote_plugin_id)?; + let events = validate_mutation_events( + captured_events, + PluginEventIdentity { + plugin_id: &expected.remote_plugin_id, + plugin_name: &expected.plugin_name, + marketplace_name: &expected.marketplace_name, + }, + )?; + if let Some(err) = uninstall_error { + return Err(err.context( + "plugin/uninstall reported an error after the backend became uninstalled", + )); + } + Ok(events) + })(); + + MutationSequenceResult { + result, + uninstall_rpc_failed, + } +} + +fn install_remote_plugin(client: &mut CodexClient, plugin: &RemotePluginExpectation) -> Result<()> { + let request_id = client.request_id(); + let _: PluginInstallResponse = client.send_request( + ClientRequest::PluginInstall { + request_id: request_id.clone(), + params: PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some(plugin.marketplace_name.clone()), + plugin_name: plugin.remote_plugin_id.clone(), + }, + }, + request_id, + "plugin/install", + )?; + Ok(()) +} + +fn uninstall_remote_plugin(client: &mut CodexClient, remote_plugin_id: &str) -> Result<()> { + let request_id = client.request_id(); + let _: PluginUninstallResponse = client.send_request( + ClientRequest::PluginUninstall { + request_id: request_id.clone(), + params: PluginUninstallParams { + plugin_id: remote_plugin_id.to_string(), + }, + }, + request_id, + "plugin/uninstall", + )?; + Ok(()) +} + +fn wait_for_installed_state( + client: &mut CodexClient, + remote_plugin_id: &str, + expected_state: ExpectedInstalledState, +) -> Result { + let deadline = Instant::now() + STATE_TIMEOUT; + loop { + match read_remote_plugin(client, remote_plugin_id) { + Ok(plugin) if plugin.installed == expected_state.is_installed() => return Ok(plugin), + Ok(_) => {} + Err(err) if Instant::now() >= deadline => return Err(err), + Err(_) => {} + } + if Instant::now() >= deadline { + bail!( + "timed out waiting for remote plugin `{remote_plugin_id}` to become {expected_state:?}" + ); + } + thread::sleep(POLL_INTERVAL); + } +} + +enum RestorationStatus { + Clean, + LocalCleanupFailure(anyhow::Error), + Dirty(anyhow::Error), + Unknown(anyhow::Error), +} + +fn restore_uninstalled_state( + client: &mut CodexClient, + remote_plugin_id: &str, +) -> RestorationStatus { + let current = match read_remote_plugin(client, remote_plugin_id) { + Ok(current) => current, + Err(err) => return RestorationStatus::Unknown(err), + }; + if !current.installed { + return RestorationStatus::Clean; + } + + let uninstall_result = uninstall_remote_plugin(client, remote_plugin_id); + match wait_for_installed_state( + client, + remote_plugin_id, + ExpectedInstalledState::Uninstalled, + ) { + Ok(_) => match uninstall_result { + Ok(()) => RestorationStatus::Clean, + Err(err) => RestorationStatus::LocalCleanupFailure(err), + }, + Err(state_err) => { + let error = match uninstall_result { + Ok(()) => state_err, + Err(uninstall_err) => anyhow!( + "cleanup uninstall failed: {uninstall_err:#}; state verification failed: {state_err:#}" + ), + }; + RestorationStatus::Dirty(error) + } + } +} + +fn wait_for_remote_plugin_event( + path: &Path, + remote_plugin_id: &str, + event_type: &str, +) -> Result<()> { + let deadline = Instant::now() + CAPTURE_TIMEOUT; + loop { + let events = read_events_for_remote_plugin(path, remote_plugin_id)?; + if events.iter().any(|event| event["event_type"] == event_type) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("timed out waiting for `{event_type}` for remote plugin `{remote_plugin_id}`"); + } + thread::sleep(POLL_INTERVAL); + } +} + +fn print_dirty_recovery( + codex_bin: &Path, + config_overrides: &[String], + remote_plugin_id: &str, + err: &anyhow::Error, +) { + eprintln!( + "FAIL-DIRTY: remote plugin `{remote_plugin_id}` still appears installed after cleanup: {err:#}" + ); + print_recovery_command(codex_bin, config_overrides, remote_plugin_id); +} + +fn print_recovery_command(codex_bin: &Path, config_overrides: &[String], remote_plugin_id: &str) { + let test_client = std::env::current_exe() + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| "codex-app-server-test-client".to_string()); + let mut command = format!( + "{} --codex-bin {}", + shell_quote(&test_client), + shell_quote(&codex_bin.display().to_string()) + ); + for override_kv in config_overrides { + command.push_str(&format!(" --config {}", shell_quote(override_kv))); + } + command.push_str(&format!( + " plugin-remote-uninstall --remote-plugin-id {} --confirm-account-mutation", + shell_quote(remote_plugin_id) + )); + eprintln!("Recovery command:"); + eprintln!(" {command}"); +} diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_smoke.rs b/codex-rs/app-server-test-client/src/plugin_analytics_smoke.rs new file mode 100644 index 000000000000..701f6361a707 --- /dev/null +++ b/codex-rs/app-server-test-client/src/plugin_analytics_smoke.rs @@ -0,0 +1,501 @@ +use super::CodexClient; +use super::loopback_responses_server::LoopbackResponsesServer; +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstalledParams; +use codex_app_server_protocol::PluginInstalledResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WriteStatus; +use serde_json::Value; +use serde_json::json; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::process; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +pub(super) const ANALYTICS_CAPTURE_ENV_VAR: &str = "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE"; +const TEST_USER_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_TEST_USER_CONFIG_FILE"; +const CAPTURE_READY_TIMEOUT: Duration = Duration::from_secs(5); +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); +const CAPTURE_POLL_INTERVAL: Duration = Duration::from_millis(50); +const PLUGIN_READY_TIMEOUT: Duration = Duration::from_secs(30); +const PLUGIN_READY_RETRY_INTERVAL: Duration = Duration::from_millis(250); +const MOCK_MODEL_SLUG: &str = "plugin-analytics-smoke"; +const MOCK_PROVIDER_ID: &str = "plugin_analytics_smoke"; + +pub(super) fn run( + codex_bin: &Path, + config_overrides: &[String], + plugin_id: &str, + capture_file: Option, +) -> Result<()> { + let capture_path = capture_file.unwrap_or_else(|| { + std::env::temp_dir().join(format!("codex-plugin-analytics-{}.jsonl", process::id())) + }); + prepare_capture_file(&capture_path)?; + + let temporary_config = TemporaryConfigFile::create()?; + let responses_server = LoopbackResponsesServer::start()?; + let mut overrides = config_overrides.to_vec(); + overrides.extend(smoke_config_overrides(responses_server.base_url())?); + + let child_environment = vec![ + ( + OsString::from(ANALYTICS_CAPTURE_ENV_VAR), + capture_path.as_os_str().to_os_string(), + ), + ( + OsString::from(TEST_USER_CONFIG_ENV_VAR), + temporary_config.path().as_os_str().to_os_string(), + ), + ]; + let mut client = CodexClient::spawn_stdio_with_env(codex_bin, &overrides, &child_environment)?; + wait_until_capture_is_ready(&capture_path)?; + client.initialize()?; + + let installed = plugin_installed(&mut client)?; + let expected = expected_plugin(&installed, plugin_id)?; + write_plugin_enabled( + &mut client, + temporary_config.path(), + plugin_id, + /*enabled*/ false, + )?; + write_plugin_enabled( + &mut client, + temporary_config.path(), + plugin_id, + /*enabled*/ true, + )?; + + wait_for_plugin_usage(&mut client, &capture_path, &expected)?; + + let events = wait_for_plugin_events(&capture_path, plugin_id)?; + let validated = validate_plugin_events(events, &expected)?; + println!( + "\n[plugin analytics smoke validated]\n{}", + serde_json::to_string_pretty(&validated)? + ); + println!("capture file: {}", capture_path.display()); + Ok(()) +} + +fn run_plugin_turn(client: &mut CodexClient, expected: &ExpectedPlugin) -> Result { + let thread = client.thread_start(ThreadStartParams { + model: Some(MOCK_MODEL_SLUG.to_string()), + model_provider: Some(MOCK_PROVIDER_ID.to_string()), + base_instructions: Some(String::new()), + developer_instructions: Some(String::new()), + ephemeral: Some(true), + ..Default::default() + })?; + let turn = client.turn_start(TurnStartParams { + thread_id: thread.thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Mention { + name: expected.plugin_name.clone(), + path: format!("plugin://{}", expected.plugin_id), + }], + ..Default::default() + })?; + client.stream_turn(&thread.thread.id, &turn.turn.id)?; + if client.last_turn_status != Some(TurnStatus::Completed) { + bail!( + "plugin analytics smoke turn did not complete: status={:?}, error={:?}", + client.last_turn_status, + client.last_turn_error_message + ); + } + Ok(turn.turn.id) +} + +fn wait_for_plugin_usage( + client: &mut CodexClient, + capture_path: &Path, + expected: &ExpectedPlugin, +) -> Result<()> { + let deadline = Instant::now() + PLUGIN_READY_TIMEOUT; + let mut attempts = 0; + loop { + attempts += 1; + let turn_id = run_plugin_turn(client, expected)?; + // Turn completion is queued after plugin usage, so its captured event is the + // barrier that tells us whether this attempt resolved the plugin. + let events = wait_for_turn_analytics(capture_path, &turn_id)?; + if events.iter().any(|event| { + event["event_type"] == "codex_plugin_used" + && event["event_params"]["turn_id"].as_str() == Some(turn_id.as_str()) + && event["event_params"]["plugin_id"].as_str() == Some(expected.plugin_id.as_str()) + }) { + if attempts > 1 { + println!("remote plugin bundle became ready after {attempts} turn attempts"); + } + return Ok(()); + } + if Instant::now() >= deadline { + bail!( + "timed out waiting for remote plugin bundle `{}` to become usable after {attempts} turn attempts", + expected.plugin_id + ); + } + thread::sleep(PLUGIN_READY_RETRY_INTERVAL); + } +} + +#[derive(Debug)] +struct ExpectedPlugin { + plugin_id: String, + plugin_name: String, + marketplace_name: String, +} + +fn plugin_installed(client: &mut CodexClient) -> Result { + let request_id = client.request_id(); + client.send_request( + ClientRequest::PluginInstalled { + request_id: request_id.clone(), + params: PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }, + }, + request_id, + "plugin/installed", + ) +} + +fn expected_plugin(response: &PluginInstalledResponse, plugin_id: &str) -> Result { + let matches = response + .marketplaces + .iter() + .flat_map(|marketplace| { + marketplace + .plugins + .iter() + .filter(move |plugin| plugin.id == plugin_id) + .map(move |plugin| (marketplace, plugin)) + }) + .collect::>(); + let [(marketplace, plugin)] = matches.as_slice() else { + bail!( + "expected exactly one installed plugin with local id `{plugin_id}`, found {}", + matches.len() + ); + }; + if !plugin.installed { + bail!("plugin `{plugin_id}` is not installed"); + } + if !plugin.enabled { + bail!("plugin `{plugin_id}` is installed remotely but disabled"); + } + if plugin.availability != PluginAvailability::Available { + bail!( + "plugin `{plugin_id}` is not available: {:?}", + plugin.availability + ); + } + plugin + .remote_plugin_id + .as_ref() + .with_context(|| format!("plugin `{plugin_id}` does not have a remote plugin id"))?; + + Ok(ExpectedPlugin { + plugin_id: plugin.id.clone(), + plugin_name: plugin.name.clone(), + marketplace_name: marketplace.name.clone(), + }) +} + +fn write_plugin_enabled( + client: &mut CodexClient, + config_path: &Path, + plugin_id: &str, + enabled: bool, +) -> Result<()> { + let request_id = client.request_id(); + let response: ConfigWriteResponse = client.send_request( + ClientRequest::ConfigValueWrite { + request_id: request_id.clone(), + params: ConfigValueWriteParams { + key_path: format!("plugins.{plugin_id}.enabled"), + value: json!(enabled), + merge_strategy: MergeStrategy::Replace, + file_path: Some(config_path.display().to_string()), + expected_version: None, + }, + }, + request_id, + "config/value/write", + )?; + println!( + "< config/value/write plugin={plugin_id} enabled={enabled} status={:?}", + response.status + ); + if response.status != WriteStatus::Ok { + bail!( + "config/value/write for plugin `{plugin_id}` enabled={enabled} was overridden: {:?}", + response.overridden_metadata + ); + } + Ok(()) +} + +fn smoke_config_overrides(responses_base_url: &str) -> Result> { + let provider_base_url = serde_json::to_string(&format!("{responses_base_url}/v1")) + .context("serialize mock provider base URL")?; + Ok(vec![ + "analytics.enabled=true".to_string(), + "features.plugins=true".to_string(), + "features.remote_plugin=true".to_string(), + format!("model={}", quoted(MOCK_MODEL_SLUG)?), + format!("model_provider={}", quoted(MOCK_PROVIDER_ID)?), + format!( + "model_providers.{MOCK_PROVIDER_ID}.name={}", + quoted("Plugin analytics smoke mock provider")? + ), + format!("model_providers.{MOCK_PROVIDER_ID}.base_url={provider_base_url}"), + format!( + "model_providers.{MOCK_PROVIDER_ID}.wire_api={}", + quoted("responses")? + ), + format!("model_providers.{MOCK_PROVIDER_ID}.requires_openai_auth=false"), + format!("model_providers.{MOCK_PROVIDER_ID}.request_max_retries=0"), + format!("model_providers.{MOCK_PROVIDER_ID}.stream_max_retries=0"), + ]) +} + +fn quoted(value: &str) -> Result { + serde_json::to_string(value).context("serialize config string") +} + +pub(super) fn prepare_capture_file(path: &Path) -> Result<()> { + let parent = path + .parent() + .context("capture file must have a parent directory")?; + if !parent.is_dir() { + bail!( + "capture file parent directory does not exist: {}", + parent.display() + ); + } + match fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err) + .with_context(|| format!("remove previous capture file {}", path.display())); + } + } + Ok(()) +} + +pub(super) fn wait_until_capture_is_ready(path: &Path) -> Result<()> { + let deadline = Instant::now() + CAPTURE_READY_TIMEOUT; + loop { + match fs::metadata(path) { + Ok(_) => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err) + .with_context(|| format!("inspect capture file {}", path.display())); + } + } + if Instant::now() >= deadline { + bail!( + "analytics capture did not become ready at {}; use a debug Codex binary", + path.display() + ); + } + thread::sleep(CAPTURE_POLL_INTERVAL); + } +} + +fn wait_for_plugin_events(path: &Path, plugin_id: &str) -> Result> { + let deadline = Instant::now() + CAPTURE_TIMEOUT; + loop { + let events = read_plugin_events(path, plugin_id)?; + if required_event_types() + .iter() + .all(|event_type| event_count(&events, event_type) >= 1) + { + return Ok(events); + } + if Instant::now() >= deadline { + bail!( + "timed out waiting for plugin analytics events in {}: found {:?}", + path.display(), + events + .iter() + .filter_map(|event| event["event_type"].as_str()) + .collect::>() + ); + } + thread::sleep(CAPTURE_POLL_INTERVAL); + } +} + +fn wait_for_turn_analytics(path: &Path, turn_id: &str) -> Result> { + let deadline = Instant::now() + CAPTURE_TIMEOUT; + loop { + let events = read_capture_events(path)?; + if events.iter().any(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"].as_str() == Some(turn_id) + }) { + return Ok(events); + } + if Instant::now() >= deadline { + bail!( + "timed out waiting for turn analytics for `{turn_id}` in {}", + path.display() + ); + } + thread::sleep(CAPTURE_POLL_INTERVAL); + } +} + +fn read_plugin_events(path: &Path, plugin_id: &str) -> Result> { + Ok(read_capture_events(path)? + .into_iter() + .filter(|event| event["event_params"]["plugin_id"] == plugin_id) + .collect()) +} + +fn read_capture_events(path: &Path) -> Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => { + return Err(err).with_context(|| format!("read capture file {}", path.display())); + } + }; + let mut captured = Vec::new(); + for (index, line) in contents.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let payload: Value = serde_json::from_str(line).with_context(|| { + format!( + "parse analytics capture line {} from {}", + index + 1, + path.display() + ) + })?; + let events = payload["events"] + .as_array() + .context("analytics capture payload is missing events")?; + captured.extend(events.iter().cloned()); + } + Ok(captured) +} + +fn validate_plugin_events(events: Vec, expected: &ExpectedPlugin) -> Result> { + let mut validated = Vec::new(); + for event_type in required_event_types() { + let matching = events + .iter() + .filter(|event| event["event_type"] == event_type) + .collect::>(); + let [event] = matching.as_slice() else { + bail!( + "expected exactly one `{event_type}` event for `{}`, found {}", + expected.plugin_id, + matching.len() + ); + }; + validate_identity(event, expected)?; + if event_type == "codex_plugin_used" { + validate_used_metadata(event)?; + } + validated.push((*event).clone()); + } + Ok(validated) +} + +fn required_event_types() -> [&'static str; 3] { + [ + "codex_plugin_disabled", + "codex_plugin_enabled", + "codex_plugin_used", + ] +} + +fn event_count(events: &[Value], event_type: &str) -> usize { + events + .iter() + .filter(|event| event["event_type"] == event_type) + .count() +} + +fn validate_identity(event: &Value, expected: &ExpectedPlugin) -> Result<()> { + let params = &event["event_params"]; + require_string(params, "plugin_id", &expected.plugin_id)?; + require_string(params, "plugin_name", &expected.plugin_name)?; + require_string(params, "marketplace_name", &expected.marketplace_name) +} + +fn validate_used_metadata(event: &Value) -> Result<()> { + let params = &event["event_params"]; + for field in [ + "has_skills", + "mcp_server_count", + "connector_ids", + "mcp_server_names", + "thread_id", + "turn_id", + "model_slug", + ] { + if params.get(field).is_none_or(Value::is_null) { + bail!("codex_plugin_used event has null or missing `{field}`"); + } + } + require_string(params, "model_slug", MOCK_MODEL_SLUG) +} + +fn require_string(params: &Value, field: &str, expected: &str) -> Result<()> { + let actual = params.get(field).and_then(Value::as_str); + if actual != Some(expected) { + bail!("expected `{field}` to be `{expected}`, got {actual:?}"); + } + Ok(()) +} + +struct TemporaryConfigFile { + path: PathBuf, +} + +impl TemporaryConfigFile { + fn create() -> Result { + let path = std::env::temp_dir().join(format!( + "codex-plugin-analytics-config-{}.toml", + process::id() + )); + fs::write(&path, "") + .with_context(|| format!("create temporary config file {}", path.display()))?; + Ok(Self { path }) + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TemporaryConfigFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} 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 000000000000..0302f336003b --- /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 000000000000..d8b2a4699d7d --- /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 175890962e76..7ad9444921e2 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 22e5e74eda23..aef2bfd25da0 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 8e75c0fc8abc..44299c4b5723 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 @@ -1038,8 +1038,10 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let (transport_event_tx, _transport_event_rx) = @@ -1866,8 +1868,10 @@ async fn remote_control_waits_for_account_id_before_enrolling() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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(); @@ -1961,8 +1965,10 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 630ce8c6b4f7..21ec02ab0625 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 @@ -182,8 +182,10 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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")); @@ -266,8 +268,10 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 ff146492fbad..380f13984eec 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 @@ -536,8 +536,10 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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")); @@ -804,8 +806,10 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 3f594a1b4e3e..b9f1c7175209 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 @@ -2211,8 +2211,10 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2308,8 +2310,10 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2430,8 +2434,10 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 eab75df7ac34..bf2c0cf0ac39 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,6 +86,13 @@ Clients must send a single `initialize` request per transport connection before `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. +Clients that handle OpenAI extended MCP forms, including a fallback for +unsupported field types, set +`initialize.params.capabilities.mcpServerOpenaiFormElicitation` to `true`. +App-server then advertises the downstream `openai/form` MCP extension for +threads started, resumed, or forked by that connection. Clients that cannot +handle the request envelope omit the field or set it to `false`. + Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. **Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If @@ -130,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 `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`. +- `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. 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. +- `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; `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`. @@ -162,13 +169,13 @@ 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". +- `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"`. -- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. +- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and, for websocket transport only, `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. For V1 sessions, pass `codexResponseHandoffPrefix` while item mode is disabled to route automatic Codex commentary through `conversation.handoff.append` with that prefix; final answers remain unprefixed. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create an AVAS/v1 WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Explicit `version: "v2"` requests are rejected for WebRTC. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. -- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user` or `developer` (experimental); returns `{}`. Older clients that omit `role` default to `user`. +- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user`, `developer`, or `assistant` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`. - `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. @@ -196,9 +203,9 @@ 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. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys (`auth_elicitation`, `memories`, `mentions_v2`, `remote_control`, `remote_plugin`, `tool_router`, `tool_suggest`). 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`; returns `{}` and does not change the default environment. +- `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. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `skills/extraRoots/set` — replace the app-server process runtime extra standalone skill roots. The roots are not persisted; missing directories are accepted and simply load no skills. @@ -233,7 +240,7 @@ Example with notification opt-out: - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Returns an `importId` used to correlate the completion notification. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes with type-level `itemResults` containing each migrated type's success count, error count, successes, and raw errors (immediately after the response when everything completed synchronously, or after background imports finish). +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. @@ -365,7 +372,8 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. -- `sortKey` — `created_at` (default) or `updated_at`. +- `sortKey` — `created_at` (default), `updated_at`, or `recency_at`. +- `recencyAt` is initialized when the thread is created and advances when a turn starts. Unlike `updatedAt`, background output and other persisted mutations do not advance it. - `sortDirection` — `desc` (default) or `asc`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). @@ -387,8 +395,8 @@ Example: } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, - { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "recencyAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "recencyAt": 1730750000, "status": { "type": "notLoaded" } } ], "nextCursor": "opaque-token-or-null", "backwardsCursor": "opaque-token-or-null" @@ -878,16 +886,27 @@ Then send `offer.sdp` to app-server. Core uses `experimental_realtime_ws_backend Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null` or `prompt: ""` when the session should start without that default backend prompt. -Clients may also pass `model` and `version` on `thread/realtime/start` to select a +Clients may also pass `model` on `thread/realtime/start` to select a different realtime session configuration without changing thread or user config. +For websocket transport, clients may pass `version` to select the realtime +protocol version for this session only. WebRTC sessions always use AVAS with +realtime v1; omitting `version` uses v1, and explicitly passing `version: "v2"` +is rejected. Pass `includeStartupContext: false` to skip Codex's startup context for this session while still using the selected backend prompt. +Pass `clientManagedHandoffs: true` to suppress automatic Codex response handoffs +and items. The client can then choose which updates to deliver with +`thread/realtime/appendText` or `thread/realtime/appendSpeech`. Pass `codexResponsesAsItems: true` to inject automatic Codex responses with `conversation.item.create` instead of the protocol's default speakable output path. When using that mode, `codexResponseItemPrefix` can prepend short experiment instructions to each automatic Codex response item. Omit `codexResponsesAsItems`, or pass `false`, to preserve the default speakable -behavior. Call +behavior. For V1 sessions, `codexResponseHandoffPrefix` instead routes automatic +Codex commentary through `conversation.handoff.append` and prepends the provided +text. Final answers remain unprefixed. Item mode takes precedence when +`codexResponsesAsItems` is true. +Call `thread/realtime/appendText` to append app-provided realtime text items, or `thread/realtime/appendSpeech` when the app decides a realtime update should be spoken. @@ -1335,6 +1354,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 }` when a response enters safety buffering. 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. @@ -1351,7 +1371,7 @@ Today both notifications carry an empty `items` array even when item events were - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). - `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. -- `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. +- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, and `resourceUri` points to the widget template. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the invoked action. `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. - `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. - `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. @@ -1430,7 +1450,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, the nullable `environmentId` where the command will run, optionally `approvalId` (for subcommand callbacks), and `reason`. New shell and unified-exec approvals set `environmentId`; older events that do not provide one are exposed as `null`. For normal command approvals, the request also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. 3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. @@ -1455,6 +1475,10 @@ When the client responds to `item/tool/requestUserInput`, the server emits `serv Desktop hosts that provide upstream attestation should set `capabilities.requestAttestation` during `initialize` and handle the server-initiated `attestation/generate` request. App-server issues it just in time before ChatGPT Codex requests that forward `x-oai-attestation`; the client responds with `{ "token": "v1." }`, where `token` is an opaque client-owned value. When app-server receives a client response, it forwards a consistent outer envelope such as `{ "v": 1, "s": 0, "t": "v1." }`, where `t` contains the client token unchanged. If app-server attempts attestation but fails within its own boundary, it sends the same envelope shape with an app-server status code and without `t` (`1 = timeout`, `2 = request failed`, `3 = request canceled`, `4 = malformed response`). If no initialized client opted into attestation, app-server omits `x-oai-attestation` for that upstream request. +### Current time + +When `[features.current_time_reminder]` is enabled with `clock_source = "external"`, app-server sends the client subscribed to the thread an experimental `currentTime/read` request with `{ "threadId": "thr_123" }` when a time reminder is due. The client responds with `{ "currentTimeAt": 1781717655 }`, where `currentTimeAt` is an integer Unix timestamp in seconds. A failed, canceled, timed-out, or malformed response stops the turn before the model request is sent. + ### MCP server elicitations MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`. @@ -1463,12 +1487,17 @@ Order of messages: 1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either: - a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }` + - an OpenAI extended form request: `{ "mode": "openai/form", "message": "...", "requestedSchema": { ... } }` - a URL request: `{ "mode": "url", "message": "...", "url": "...", "elicitationId": "..." }` 2. Client response — `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`. 3. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. +For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The +client owns validation and rendering of supported field types and must return a +valid `decline` or `cancel` response when it cannot render a form. + For MCP tool approval elicitations, form request `meta` includes `codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, `persist: "always"`, or `persist: ["session", "always"]` to advertise whether @@ -1826,15 +1855,24 @@ approvals_reviewer = "auto_review" [apps._default] approvals_reviewer = "user" +default_tools_approval_mode = "prompt" [apps.demo-app] approvals_reviewer = "auto_review" +default_tools_approval_mode = "approve" ``` Setting the app value to `"user"` routes its approval prompts to the user instead of Guardian; setting it to `"auto_review"` opts that app into Guardian review when allowed by configuration requirements. +Use `apps._default.default_tools_approval_mode` to set the approval mode for +tools without a per-app or per-tool override. Supported values are `"auto"`, +`"prompt"`, and `"approve"`. Tool-level `approval_mode` takes precedence over +the per-app `default_tools_approval_mode`, which takes precedence over the +`apps._default` value. Managed tool requirements take precedence over all of +these settings. When none are configured, the mode defaults to `"auto"`. + Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/installed` or `plugin/list`. Example: @@ -1884,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? }`. @@ -1937,12 +1976,17 @@ Response examples: "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 } } ``` 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. ### 2) Log in with an API key @@ -2040,7 +2084,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 868cef81fc5c..bc695050669b 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; @@ -113,6 +114,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestU use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -129,7 +131,7 @@ enum CommandExecutionApprovalPresentation { #[derive(Debug, PartialEq)] struct CommandExecutionCompletionItem { command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, command_actions: Vec, } @@ -350,6 +352,20 @@ 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, + }; + outgoing + .send_server_notification(ServerNotification::ModelSafetyBufferingUpdated( + notification, + )) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), @@ -550,6 +566,7 @@ pub(crate) async fn apply_bespoke_event_handling( call_id, approval_id, turn_id, + environment_id, started_at_ms, command, cwd, @@ -574,7 +591,7 @@ pub(crate) async fn apply_bespoke_event_handling( let command_string = shlex_join(&command); let completion_item = CommandExecutionCompletionItem { command: command_string, - cwd: cwd.clone(), + cwd: cwd.clone().into(), command_actions: command_actions.clone(), }; CommandExecutionApprovalPresentation::Command(completion_item) @@ -626,6 +643,7 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: call_id.clone(), started_at_ms, approval_id: approval_id.clone(), + environment_id, reason, network_approval_context, command, @@ -1352,7 +1370,7 @@ async fn start_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, command_actions: Vec, source: CommandExecutionSource, outgoing: &ThreadScopedOutgoingMessageSender, @@ -1396,7 +1414,7 @@ async fn complete_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, process_id: Option, source: CommandExecutionSource, command_actions: Vec, @@ -2259,6 +2277,7 @@ mod tests { reasoning_effort: None, created_at, updated_at: created_at, + recency_at: created_at, archived_at: None, cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), @@ -2320,7 +2339,7 @@ mod tests { fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), command_actions: vec![V2ParsedCommand::Unknown { command: command.to_string(), }], diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 2f62f099e95d..d7f0b90d5c88 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -237,6 +237,10 @@ impl CommandExecManager { arg0, .. } = exec_request; + // TODO(anp): Keep PathUri through the local command launch boundary. + let cwd = cwd + .to_abs_path() + .map_err(|err| invalid_request(format!("invalid command cwd: {err}")))?; let stream_stdin = tty || stream_stdin; let stream_stdout_stderr = tty || stream_stdout_stderr; @@ -700,6 +704,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::DefaultTimeout, codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::WindowsRestrictedToken, @@ -817,6 +822,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::Cancellation(CancellationToken::new()), codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::None, @@ -904,6 +910,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::TimeoutOrCancellation { timeout: Duration::from_secs(30), cancellation, diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 7b7f0f2e996b..ce10d6e81cb3 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -129,11 +129,6 @@ impl ExternalAgentConfigImportItemResult { } } - pub(crate) fn record_successes(&mut self, count: usize) { - let count = u32::try_from(count).unwrap_or(u32::MAX); - self.success_count = self.success_count.saturating_add(count); - } - pub(crate) fn record_error(&mut self, raw_error: ExternalAgentConfigImportRawError) { self.error_count = self.error_count.saturating_add(1); self.raw_errors.push(raw_error); @@ -161,6 +156,7 @@ pub(crate) struct ExternalAgentConfigImportSuccess { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ExternalAgentConfigImportRawError { pub item_type: ExternalAgentConfigMigrationItemType, + pub error_type: Option, pub failure_stage: String, pub message: String, pub cwd: Option, @@ -241,7 +237,7 @@ impl ExternalAgentConfigService { pub(crate) async fn import( &self, migration_items: Vec, - ) -> io::Result { + ) -> ExternalAgentConfigImportOutcome { let mut outcome = ExternalAgentConfigImportOutcome::default(); for migration_item in migration_items { let item_type = migration_item.item_type; @@ -254,33 +250,41 @@ impl ExternalAgentConfigService { ); let import_result = match migration_item.item_type { ExternalAgentConfigMigrationItemType::Config => (|| { - let migrated_count = self.import_config(migration_item.cwd.as_deref())?; + if let Some((source, target)) = + self.import_config(migration_item.cwd.as_deref())? + { + item_result.record_success(Some(source), Some(target)); + } emit_migration_metric( EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::Config, /*skills_count*/ None, ); - item_result.record_successes(migrated_count); Ok(()) })(), ExternalAgentConfigMigrationItemType::Skills => (|| { - let skills_count = self.import_skills(migration_item.cwd.as_deref())?; + let imported_skills = self.import_skills(migration_item.cwd.as_deref())?; emit_migration_metric( EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::Skills, - Some(skills_count), + Some(imported_skills.len()), ); - item_result.record_successes(skills_count); + for skill_name in imported_skills { + item_result.record_success(Some(skill_name.clone()), Some(skill_name)); + } Ok(()) })(), ExternalAgentConfigMigrationItemType::AgentsMd => (|| { - let migrated_count = self.import_agents_md(migration_item.cwd.as_deref())?; + if let Some((source, target)) = + self.import_agents_md(migration_item.cwd.as_deref())? + { + item_result.record_success(Some(source), Some(target)); + } emit_migration_metric( EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::AgentsMd, /*skills_count*/ None, ); - item_result.record_successes(migrated_count); Ok(()) })(), ExternalAgentConfigMigrationItemType::Plugins => { @@ -357,59 +361,80 @@ impl ExternalAgentConfigService { .await } ExternalAgentConfigMigrationItemType::McpServerConfig => (|| { - let migrated_count = + let migrated_server_names = self.import_mcp_server_config(migration_item.cwd.as_deref())?; emit_migration_metric( EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::McpServerConfig, /*skills_count*/ None, ); - item_result.record_successes(migrated_count); + for server_name in migrated_server_names { + item_result.record_success(Some(server_name.clone()), Some(server_name)); + } Ok(()) })(), ExternalAgentConfigMigrationItemType::Subagents => (|| { - let subagents_count = self.import_subagents(migration_item.cwd.as_deref())?; + let imported_subagents = + self.import_subagents(migration_item.cwd.as_deref())?; emit_migration_metric( EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::Subagents, - Some(subagents_count), + Some(imported_subagents.len()), ); - item_result.record_successes(subagents_count); + for subagent_name in imported_subagents { + item_result + .record_success(Some(subagent_name.clone()), Some(subagent_name)); + } Ok(()) })(), ExternalAgentConfigMigrationItemType::Hooks => (|| { - let migrated_count = self.import_hooks(migration_item.cwd.as_deref())?; + let migrated_hook_names = self.import_hooks(migration_item.cwd.as_deref())?; emit_migration_metric( EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::Hooks, /*skills_count*/ None, ); - item_result.record_successes(migrated_count); + for hook_name in migrated_hook_names { + item_result.record_success(Some(hook_name.clone()), Some(hook_name)); + } Ok(()) })(), ExternalAgentConfigMigrationItemType::Commands => (|| { - let commands_count = self.import_commands(migration_item.cwd.as_deref())?; + let imported_commands = self.import_commands(migration_item.cwd.as_deref())?; emit_migration_metric( EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, ExternalAgentConfigMigrationItemType::Commands, - Some(commands_count), + Some(imported_commands.len()), ); - item_result.record_successes(commands_count); + for command_name in imported_commands { + item_result.record_success(Some(command_name.clone()), Some(command_name)); + } Ok(()) })(), ExternalAgentConfigMigrationItemType::Sessions => Ok(()), }; - if let Err(err) = import_result { - if item_type == ExternalAgentConfigMigrationItemType::Plugins { - outcome.item_results.push(item_result); - continue; - } - return Err(err); + if let Err(err) = import_result + && item_type != ExternalAgentConfigMigrationItemType::Plugins + { + let message = err.to_string(); + let error_type = if message.contains("invalid existing config.toml") { + "invalid_existing_config" + } else { + "external_agent_config_import_error" + }; + item_result.record_error(ExternalAgentConfigImportRawError { + item_type, + error_type: Some(error_type.to_string()), + failure_stage: "import_request_failed".to_string(), + message, + cwd: item_result.cwd.clone(), + source: None, + }); } outcome.item_results.push(item_result); } - Ok(outcome) + outcome } async fn detect_migrations( @@ -957,7 +982,7 @@ impl ExternalAgentConfigService { Ok(outcome) } - fn import_config(&self, cwd: Option<&Path>) -> io::Result { + fn import_config(&self, cwd: Option<&Path>) -> io::Result> { let repo_root = find_repo_root(cwd)?; let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { ( @@ -965,7 +990,7 @@ impl ExternalAgentConfigService { repo_root.join(".codex").join("config.toml"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { - return Ok(0); + return Ok(None); } else { ( self.external_agent_home.join("settings.json"), @@ -973,11 +998,11 @@ impl ExternalAgentConfigService { ) }; let Some(settings) = effective_external_settings(&source_settings)? else { - return Ok(0); + return Ok(None); }; let migrated = build_config_from_external(&settings)?; if is_empty_toml_table(&migrated) { - return Ok(0); + return Ok(None); } let Some(target_parent) = target_config.parent() else { @@ -986,7 +1011,10 @@ impl ExternalAgentConfigService { fs::create_dir_all(target_parent)?; if !target_config.exists() { write_toml_file(&target_config, &migrated)?; - return Ok(1); + return Ok(Some(( + source_settings.display().to_string(), + target_config.display().to_string(), + ))); } let existing_raw = fs::read_to_string(&target_config)?; @@ -999,14 +1027,17 @@ impl ExternalAgentConfigService { let changed = merge_missing_toml_values(&mut existing, &migrated)?; if !changed { - return Ok(0); + return Ok(None); } write_toml_file(&target_config, &existing)?; - Ok(1) + Ok(Some(( + source_settings.display().to_string(), + target_config.display().to_string(), + ))) } - fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result { + fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result> { let repo_root = find_repo_root(cwd)?; let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { ( @@ -1014,7 +1045,7 @@ impl ExternalAgentConfigService { repo_root.join(".codex").join("config.toml"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { - return Ok(0); + return Ok(Vec::new()); } else { ( self.external_agent_home.join("settings.json"), @@ -1031,7 +1062,7 @@ impl ExternalAgentConfigService { settings.as_ref(), )?; if is_empty_toml_table(&migrated) { - return Ok(0); + return Ok(Vec::new()); } let Some(target_parent) = target_config.parent() else { @@ -1039,9 +1070,9 @@ impl ExternalAgentConfigService { }; fs::create_dir_all(target_parent)?; if !target_config.exists() { - let migrated_count = migrated_mcp_server_names(&migrated).len(); + let migrated_server_names = migrated_mcp_server_names(&migrated); write_toml_file(&target_config, &migrated)?; - return Ok(migrated_count); + return Ok(migrated_server_names); } let existing_raw = fs::read_to_string(&target_config)?; @@ -1051,21 +1082,21 @@ impl ExternalAgentConfigService { toml::from_str::(&existing_raw) .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))? }; - let merged_server_count = merge_missing_mcp_servers(&mut existing, &migrated)?.len(); - if merged_server_count > 0 { + let merged_server_names = merge_missing_mcp_servers(&mut existing, &migrated)?; + if !merged_server_names.is_empty() { write_toml_file(&target_config, &existing)?; } - Ok(merged_server_count) + Ok(merged_server_names) } - fn import_subagents(&self, cwd: Option<&Path>) -> io::Result { + fn import_subagents(&self, cwd: Option<&Path>) -> io::Result> { let (source_agents, target_agents) = if let Some(repo_root) = find_repo_root(cwd)? { ( repo_root.join(EXTERNAL_AGENT_DIR).join("agents"), repo_root.join(".codex").join("agents"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { - return Ok(0); + return Ok(Vec::new()); } else { ( self.external_agent_home.join("agents"), @@ -1076,7 +1107,7 @@ impl ExternalAgentConfigService { import_subagents(&source_agents, &target_agents) } - fn import_hooks(&self, cwd: Option<&Path>) -> io::Result { + fn import_hooks(&self, cwd: Option<&Path>) -> io::Result> { let (source_external_agent_dir, target_hooks) = if let Some(repo_root) = find_repo_root(cwd)? { ( @@ -1084,7 +1115,7 @@ impl ExternalAgentConfigService { repo_root.join(".codex").join("hooks.json"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { - return Ok(0); + return Ok(Vec::new()); } else { ( self.external_agent_home.clone(), @@ -1092,20 +1123,22 @@ impl ExternalAgentConfigService { ) }; - Ok(usize::from(import_hooks( - &source_external_agent_dir, - &target_hooks, - )?)) + let hook_names = hook_migration_event_names(&source_external_agent_dir, &target_hooks)?; + if import_hooks(&source_external_agent_dir, &target_hooks)? { + Ok(hook_names) + } else { + Ok(Vec::new()) + } } - fn import_commands(&self, cwd: Option<&Path>) -> io::Result { + fn import_commands(&self, cwd: Option<&Path>) -> io::Result> { let (source_commands, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? { ( repo_root.join(EXTERNAL_AGENT_DIR).join("commands"), repo_root.join(".agents").join("skills"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { - return Ok(0); + return Ok(Vec::new()); } else { ( self.external_agent_home.join("commands"), @@ -1116,14 +1149,14 @@ impl ExternalAgentConfigService { import_commands(&source_commands, &target_skills) } - fn import_skills(&self, cwd: Option<&Path>) -> io::Result { + fn import_skills(&self, cwd: Option<&Path>) -> io::Result> { let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? { ( repo_root.join(EXTERNAL_AGENT_DIR).join("skills"), repo_root.join(".agents").join("skills"), ) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { - return Ok(0); + return Ok(Vec::new()); } else { ( self.external_agent_home.join("skills"), @@ -1131,11 +1164,11 @@ impl ExternalAgentConfigService { ) }; if !source_skills.is_dir() { - return Ok(0); + return Ok(Vec::new()); } fs::create_dir_all(&target_skills)?; - let mut copied_count = 0usize; + let mut copied_names = Vec::new(); for entry in fs::read_dir(&source_skills)? { let entry = entry?; @@ -1150,20 +1183,20 @@ impl ExternalAgentConfigService { } copy_dir_recursive(&entry.path(), &target)?; - copied_count += 1; + copied_names.push(entry.file_name().to_string_lossy().to_string()); } - Ok(copied_count) + Ok(copied_names) } - fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result { + fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result> { let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? { let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else { - return Ok(0); + return Ok(None); }; (source_agents_md, repo_root.join("AGENTS.md")) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { - return Ok(0); + return Ok(None); } else { ( self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD), @@ -1173,7 +1206,7 @@ impl ExternalAgentConfigService { if !is_non_empty_text_file(&source_agents_md)? || !is_missing_or_empty_text_file(&target_agents_md)? { - return Ok(0); + return Ok(None); } let Some(target_parent) = target_agents_md.parent() else { @@ -1182,7 +1215,10 @@ impl ExternalAgentConfigService { fs::create_dir_all(target_parent)?; rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)?; - Ok(1) + Ok(Some(( + source_agents_md.display().to_string(), + target_agents_md.display().to_string(), + ))) } } @@ -1826,6 +1862,7 @@ pub(crate) fn record_import_error( ) { result.record_error(ExternalAgentConfigImportRawError { item_type: result.item_type, + error_type: None, failure_stage: failure_stage.to_string(), message: message.into(), cwd: result.cwd.clone(), @@ -1856,6 +1893,7 @@ fn plugin_import_raw_error( ) -> ExternalAgentConfigImportRawError { ExternalAgentConfigImportRawError { item_type: ExternalAgentConfigMigrationItemType::Plugins, + error_type: None, failure_stage: failure_stage.to_string(), message, cwd: cwd.map(Path::to_path_buf), diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 6675dc32ec91..447741d14ab1 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -47,11 +47,26 @@ fn assert_single_plugin_raw_error( ExternalAgentConfigMigrationItemType::Plugins ); assert_eq!(raw_error.failure_stage, failure_stage); + assert_eq!(raw_error.error_type, None); assert_eq!(raw_error.cwd, None); assert_eq!(raw_error.source.as_deref(), Some(source)); assert!(!raw_error.message.is_empty()); } +fn import_success( + item_type: ExternalAgentConfigMigrationItemType, + cwd: Option, + source: impl Into, + target: impl Into, +) -> ExternalAgentConfigImportSuccess { + ExternalAgentConfigImportSuccess { + item_type, + cwd, + source: Some(source.into()), + target: Some(target.into()), + } +} + #[tokio::test] async fn detect_home_lists_config_skills_and_agents_md() { let (_root, external_agent_home, codex_home) = fixture_paths(); @@ -546,8 +561,7 @@ async fn import_repo_migrates_mcp_hooks_commands_and_subagents() { details: None, }, ]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -709,8 +723,7 @@ url = "https://example.com/mixed-transport" cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -823,8 +836,7 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), @@ -879,8 +891,7 @@ async fn import_home_config_uses_local_settings_over_project_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) @@ -924,8 +935,7 @@ async fn import_home_config_ignores_invalid_local_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("config.toml")).expect("read config"), @@ -950,8 +960,7 @@ async fn import_home_skips_empty_config_migration() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1028,8 +1037,7 @@ async fn import_local_plugins_returns_completed_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1089,8 +1097,7 @@ async fn import_git_plugins_returns_pending_async_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1220,8 +1227,7 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1232,7 +1238,15 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, - successes: Vec::new(), + successes: vec![import_success( + ExternalAgentConfigMigrationItemType::AgentsMd, + Some(repo_root.clone()), + repo_root + .join(EXTERNAL_AGENT_CONFIG_MD) + .display() + .to_string(), + repo_root.join("AGENTS.md").display().to_string(), + )], raw_errors: Vec::new(), }, ExternalAgentConfigImportItemResult { @@ -1279,8 +1293,7 @@ async fn import_repo_agents_md_overwrites_empty_targets() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1290,7 +1303,15 @@ async fn import_repo_agents_md_overwrites_empty_targets() { cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, - successes: Vec::new(), + successes: vec![import_success( + ExternalAgentConfigMigrationItemType::AgentsMd, + Some(repo_root.clone()), + repo_root + .join(EXTERNAL_AGENT_CONFIG_MD) + .display() + .to_string(), + repo_root.join("AGENTS.md").display().to_string(), + )], raw_errors: Vec::new(), }] ); @@ -1372,8 +1393,7 @@ async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1383,7 +1403,12 @@ async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() { cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, - successes: Vec::new(), + successes: vec![import_success( + ExternalAgentConfigMigrationItemType::Hooks, + Some(repo_root.clone()), + "Stop", + "Stop", + )], raw_errors: Vec::new(), }] ); @@ -1445,8 +1470,7 @@ async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1456,7 +1480,12 @@ async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, - successes: Vec::new(), + successes: vec![import_success( + ExternalAgentConfigMigrationItemType::McpServerConfig, + Some(repo_root.clone()), + "allowed", + "allowed", + )], raw_errors: Vec::new(), }] ); @@ -1518,8 +1547,7 @@ async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1566,8 +1594,7 @@ async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missin cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1608,8 +1635,7 @@ async fn import_repo_uses_non_empty_external_agent_agents_source() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), @@ -1642,8 +1668,7 @@ async fn import_continues_after_failed_migration_item() { details: None, }, ]) - .await - .expect("import continues"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), @@ -2745,7 +2770,7 @@ async fn import_plugins_supports_project_relative_external_agent_plugin_marketpl } #[test] -fn import_skills_returns_only_new_skill_directory_count() { +fn import_skills_returns_only_new_skill_directory_names() { let (_root, external_agent_home, codex_home) = fixture_paths(); let agents_skills = codex_home .parent() @@ -2757,9 +2782,9 @@ fn import_skills_returns_only_new_skill_directory_count() { .expect("create source b"); fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target"); - let copied_count = service_for_paths(external_agent_home, codex_home) + let copied_names = service_for_paths(external_agent_home, codex_home) .import_skills(/*cwd*/ None) .expect("import skills"); - assert_eq!(copied_count, 1); + assert_eq!(copied_names, vec!["skill-b".to_string()]); } diff --git a/codex-rs/app-server/src/current_time.rs b/codex-rs/app-server/src/current_time.rs new file mode 100644 index 000000000000..9b76d337a2c7 --- /dev/null +++ b/codex-rs/app-server/src/current_time.rs @@ -0,0 +1,146 @@ +use std::sync::Arc; +use std::sync::Weak; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use chrono::DateTime; +use chrono::Utc; +use codex_app_server_protocol::CurrentTimeReadParams; +use codex_app_server_protocol::CurrentTimeReadResponse; +use codex_app_server_protocol::ServerRequestPayload; +use codex_core::TimeFuture; +use codex_core::TimeProvider; +use codex_protocol::ThreadId; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::timeout_at; + +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; +use crate::thread_state::ThreadStateManager; + +const CURRENT_TIME_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) fn app_server_time_provider( + outgoing: Arc, + thread_state_manager: ThreadStateManager, +) -> Arc { + Arc::new(AppServerTimeProvider { + outgoing: Arc::downgrade(&outgoing), + thread_state_manager, + }) +} + +struct AppServerTimeProvider { + outgoing: Weak, + thread_state_manager: ThreadStateManager, +} + +impl TimeProvider for AppServerTimeProvider { + fn current_time(&self, thread_id: ThreadId) -> TimeFuture<'_> { + let outgoing = self.outgoing.clone(); + let thread_state_manager = self.thread_state_manager.clone(); + Box::pin(async move { + let outgoing = outgoing + .upgrade() + .context("app-server current-time provider is unavailable")?; + request_current_time(outgoing, thread_state_manager, thread_id).await + }) + } +} + +async fn request_current_time( + outgoing: Arc, + thread_state_manager: ThreadStateManager, + thread_id: ThreadId, +) -> Result> { + let deadline = Instant::now() + CURRENT_TIME_REQUEST_TIMEOUT; + timeout_at( + deadline, + thread_state_manager.wait_for_thread_subscriber(thread_id), + ) + .await + .map_err(|_| { + anyhow!( + "timed out waiting for a client to subscribe to the thread after {}s", + CURRENT_TIME_REQUEST_TIMEOUT.as_secs() + ) + })?; + let connection_ids = thread_state_manager + .subscribed_connection_ids(thread_id) + .await; + let connection_id = require_single_current_time_connection(&connection_ids)?; + let connection_ids = [connection_id]; + let (request_id, rx) = outgoing + .send_request_to_connections( + Some(&connection_ids), + ServerRequestPayload::CurrentTimeRead(CurrentTimeReadParams { + thread_id: thread_id.to_string(), + }), + /*thread_id*/ None, + ) + .await; + + let result = match timeout_at(deadline, rx).await { + Ok(Ok(Ok(result))) => result, + Ok(Ok(Err(err))) => { + bail!( + "current-time request failed: code={} message={}", + err.code, + err.message + ); + } + Ok(Err(err)) => bail!("current-time request was canceled: {err}"), + Err(_) => { + let _canceled = outgoing.cancel_request(&request_id).await; + bail!( + "current-time request timed out after {}s", + CURRENT_TIME_REQUEST_TIMEOUT.as_secs() + ); + } + }; + let response: CurrentTimeReadResponse = + serde_json::from_value(result).context("invalid current-time response")?; + + DateTime::from_timestamp(response.current_time_at, 0) + .ok_or_else(|| anyhow!("current-time response is outside the supported range")) +} + +fn require_single_current_time_connection(connection_ids: &[ConnectionId]) -> Result { + // External clocks are not interchangeable, so do not choose one silently. + match connection_ids { + [connection_id] => Ok(*connection_id), + _ => bail!( + "expected exactly one client subscribed to the thread, found {}", + connection_ids.len() + ), + } +} + +#[cfg(test)] +mod tests { + use super::require_single_current_time_connection; + use crate::outgoing_message::ConnectionId; + + #[test] + fn current_time_connection_must_be_unambiguous() { + assert_eq!( + require_single_current_time_connection(&[ConnectionId(7)]).unwrap(), + ConnectionId(7) + ); + assert_eq!( + require_single_current_time_connection(&[]) + .unwrap_err() + .to_string(), + "expected exactly one client subscribed to the thread, found 0" + ); + assert_eq!( + require_single_current_time_connection(&[ConnectionId(7), ConnectionId(8)]) + .unwrap_err() + .to_string(), + "expected exactly one client subscribed to the thread, found 2" + ); + } +} diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 8a6bc07f3de8..d804e139bfe7 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -88,6 +88,7 @@ where |config: &Config| codex_skills_extension::SkillsExtensionConfig { include_instructions: config.include_skill_instructions, bundled_skills_enabled: config.bundled_skills_enabled(), + orchestrator_skills_enabled: config.orchestrator_skills_enabled, }, ); Arc::new(builder.build()) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 3b02d7e3d979..264c28e09898 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -10,6 +10,8 @@ use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::resolve_installation_id; use codex_login::AuthManager; +#[cfg(debug_assertions)] +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; use std::collections::HashSet; @@ -66,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; @@ -89,6 +89,7 @@ mod config_manager; mod config_manager_service; mod connection_cleanup; mod connection_rpc_gate; +mod current_time; mod dynamic_tools; mod error_code; mod extensions; @@ -99,6 +100,7 @@ pub mod in_process; mod mcp_refresh; mod message_processor; mod models; +mod models_refresh_worker; mod outgoing_message; mod request_processors; mod request_serialization; @@ -120,6 +122,8 @@ pub use crate::transport::take_remote_control_disabled_env; const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT"; const OTEL_SERVICE_NAME: &str = "codex-app-server"; +#[cfg(debug_assertions)] +const TEST_USER_CONFIG_FILE_ENV_VAR: &str = "CODEX_APP_SERVER_TEST_USER_CONFIG_FILE"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum LogFormat { @@ -437,6 +441,10 @@ pub async fn run_main_with_transport_options( auth: AppServerWebsocketAuthSettings, runtime_options: AppServerRuntimeOptions, ) -> IoResult<()> { + let loader_overrides = loader_overrides_with_test_user_config_file( + loader_overrides, + test_user_config_file_from_env(), + )?; let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -656,7 +664,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() @@ -1295,6 +1303,43 @@ fn emit_state_db_backup_warning(message: &str) { } } +fn test_user_config_file_from_env() -> Option { + #[cfg(debug_assertions)] + { + std::env::var_os(TEST_USER_CONFIG_FILE_ENV_VAR) + .filter(|value| !value.is_empty()) + .map(std::path::PathBuf::from) + } + + #[cfg(not(debug_assertions))] + None +} + +fn loader_overrides_with_test_user_config_file( + mut loader_overrides: LoaderOverrides, + test_user_config_file: Option, +) -> IoResult { + #[cfg(debug_assertions)] + if let Some(path) = test_user_config_file { + let path = AbsolutePathBuf::from_absolute_path(path).map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!("invalid test user config path: {err}"), + ) + })?; + warn!( + path = %path.as_path().display(), + "using debug-only app-server test user config file" + ); + loader_overrides.user_config_path = Some(path); + } + + #[cfg(not(debug_assertions))] + let _ = test_user_config_file; + + Ok(loader_overrides) +} + fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport { match transport { AppServerTransport::Stdio => AppServerRpcTransport::Stdio, @@ -1307,6 +1352,12 @@ fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransp #[cfg(test)] mod tests { use super::LogFormat; + #[cfg(debug_assertions)] + use super::loader_overrides_with_test_user_config_file; + #[cfg(debug_assertions)] + use codex_config::LoaderOverrides; + #[cfg(debug_assertions)] + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[test] @@ -1326,4 +1377,20 @@ mod tests { assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); } + + #[cfg(debug_assertions)] + #[test] + fn debug_test_user_config_file_overrides_loader_path() { + let path = std::env::temp_dir().join("codex-app-server-test-config.toml"); + let loader_overrides = loader_overrides_with_test_user_config_file( + LoaderOverrides::default(), + Some(path.clone()), + ) + .expect("test config path should be valid"); + + assert_eq!( + loader_overrides.user_config_path, + Some(AbsolutePathBuf::from_absolute_path(path).expect("absolute test path")) + ); + } } diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index a5659174f465..84a6b8e83b6d 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -250,6 +250,7 @@ mod tests { Some(state_db.clone()), "11111111-1111-4111-8111-111111111111".to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ) }); thread_manager.start_thread(good_config).await?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e64e203a2535..da674db5f826 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -7,6 +7,7 @@ use std::sync::atomic::AtomicBool; use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; +use crate::current_time::app_server_time_provider; use crate::error_code::invalid_request; use crate::extensions::ThreadExtensionDependencies; use crate::extensions::app_server_extension_event_sink; @@ -24,6 +25,7 @@ use crate::request_processors::CommandExecRequestProcessor; use crate::request_processors::ConfigRequestProcessor; use crate::request_processors::EnvironmentRequestProcessor; use crate::request_processors::ExternalAgentConfigRequestProcessor; +use crate::request_processors::ExternalAgentConfigRequestProcessorArgs; use crate::request_processors::FeedbackRequestProcessor; use crate::request_processors::FsRequestProcessor; use crate::request_processors::GitRequestProcessor; @@ -91,6 +93,8 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use tracing::Instrument; +use crate::models_refresh_worker::ModelsRefreshWorker; + const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); @@ -181,6 +185,7 @@ impl ExternalAuth for ExternalAuthRefreshBridge { pub(crate) struct MessageProcessor { outgoing: Arc, + models_refresh_worker: ModelsRefreshWorker, skills_watcher: Arc, account_processor: AccountRequestProcessor, apps_processor: AppsRequestProcessor, @@ -219,6 +224,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, + pub(crate) supports_openai_form_elicitation: bool, } impl Default for ConnectionSessionState { @@ -270,6 +276,11 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } + pub(crate) fn supports_openai_form_elicitation(&self) -> bool { + self.initialized + .get() + .is_some_and(|session| session.supports_openai_form_elicitation) + } pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -368,12 +379,18 @@ impl MessageProcessor { outgoing.clone(), thread_state_manager.clone(), )), + Some(app_server_time_provider( + outgoing.clone(), + thread_state_manager.clone(), + )), ) }); + let models_manager = thread_manager.get_models_manager(); + let models_refresh_worker = crate::models_refresh_worker::spawn(&models_manager); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); - let skills_watcher = SkillsWatcher::new(thread_manager.skills_manager(), outgoing.clone()); + let skills_watcher = SkillsWatcher::new(thread_manager.skills_service(), outgoing.clone()); let pending_thread_unloads = Arc::new(Mutex::new(HashSet::new())); let thread_watch_manager = @@ -475,7 +492,7 @@ impl MessageProcessor { thread_watch_manager.clone(), Arc::clone(&thread_list_state_permit), thread_goal_processor.clone(), - state_db, + state_db.clone(), log_db, Arc::clone(&skills_watcher), ); @@ -509,17 +526,20 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), thread_manager.clone(), - analytics_events_client, - ); - let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( - outgoing.clone(), - Arc::clone(&thread_manager), - Arc::clone(&thread_store), - config_manager.clone(), - config_processor.clone(), - arg0_paths, - config.codex_home.to_path_buf(), + analytics_events_client.clone(), ); + let external_agent_config_processor = + ExternalAgentConfigRequestProcessor::new(ExternalAgentConfigRequestProcessorArgs { + outgoing: outgoing.clone(), + thread_manager: Arc::clone(&thread_manager), + thread_store: Arc::clone(&thread_store), + config_manager: config_manager.clone(), + config_processor: config_processor.clone(), + state_db, + analytics_events_client, + arg0_paths, + codex_home: config.codex_home.to_path_buf(), + }); let environment_processor = EnvironmentRequestProcessor::new(thread_manager.environment_manager()); let fs_processor = FsRequestProcessor::new( @@ -534,6 +554,7 @@ impl MessageProcessor { Self { outgoing, + models_refresh_worker, skills_watcher, account_processor, apps_processor, @@ -563,6 +584,7 @@ impl MessageProcessor { pub(crate) fn clear_runtime_references(&self) { self.account_processor.clear_external_auth(); self.apps_processor.shutdown(); + self.models_refresh_worker.shutdown(); self.skills_watcher.shutdown(); } @@ -739,6 +761,7 @@ impl MessageProcessor { } pub(crate) async fn drain_background_tasks(&self) { + self.models_refresh_worker.shutdown(); self.thread_processor.drain_background_tasks().await; } @@ -872,6 +895,7 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); + let supports_openai_form_elicitation = session.supports_openai_form_elicitation(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -887,6 +911,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, + supports_openai_form_elicitation, ) .await; if let Err(error) = result { @@ -916,6 +941,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -947,6 +973,11 @@ impl MessageProcessor { .import(request_id.clone(), params) .await .map(|()| None), + ClientRequest::ExternalAgentConfigImportHistoriesRead { .. } => self + .external_agent_config_processor + .read_import_histories() + .await + .map(|response| Some(response.into())), ClientRequest::ConfigValueWrite { params, .. } => { self.config_processor.value_write(params).await.map(Some) } @@ -1063,6 +1094,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + supports_openai_form_elicitation, request_context, ) .await @@ -1079,6 +1111,8 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ + supports_openai_form_elicitation, ) .await } @@ -1089,6 +1123,8 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ + supports_openai_form_elicitation, ) .await } @@ -1293,6 +1329,8 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ + supports_openai_form_elicitation, ) .await } @@ -1394,6 +1432,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/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 771b1fd128b8..7c4ae8c98563 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -673,6 +673,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { personality: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, }, }, Some(remote_trace), diff --git a/codex-rs/app-server/src/models_refresh_worker.rs b/codex-rs/app-server/src/models_refresh_worker.rs new file mode 100644 index 000000000000..475c8acabf54 --- /dev/null +++ b/codex-rs/app-server/src/models_refresh_worker.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; +use std::time::Duration; + +use codex_models_manager::manager::RefreshStrategy; +use codex_models_manager::manager::SharedModelsManager; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +const MODELS_REFRESH_INTERVAL: Duration = Duration::from_secs(3 * 60); + +#[derive(Debug)] +pub(crate) struct ModelsRefreshWorker { + shutdown: CancellationToken, + _task: JoinHandle<()>, +} + +impl ModelsRefreshWorker { + pub(crate) fn shutdown(&self) { + self.shutdown.cancel(); + } +} + +impl Drop for ModelsRefreshWorker { + fn drop(&mut self) { + self.shutdown(); + } +} + +pub(crate) fn spawn(models_manager: &SharedModelsManager) -> ModelsRefreshWorker { + spawn_with_interval(models_manager, MODELS_REFRESH_INTERVAL) +} + +fn spawn_with_interval( + models_manager: &SharedModelsManager, + refresh_interval: Duration, +) -> ModelsRefreshWorker { + let models_manager = Arc::downgrade(models_manager); + let shutdown = CancellationToken::new(); + let worker_shutdown = shutdown.clone(); + let task = tokio::spawn(async move { + loop { + if worker_shutdown.is_cancelled() { + break; + } + let Some(models_manager) = models_manager.upgrade() else { + break; + }; + models_manager.list_models(RefreshStrategy::Online).await; + drop(models_manager); + + tokio::select! { + _ = worker_shutdown.cancelled() => break, + _ = tokio::time::sleep(refresh_interval) => {} + } + } + }); + ModelsRefreshWorker { + shutdown, + _task: task, + } +} + +#[cfg(test)] +#[path = "models_refresh_worker_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/models_refresh_worker_tests.rs b/codex-rs/app-server/src/models_refresh_worker_tests.rs new file mode 100644 index 000000000000..e90f1284d3ad --- /dev/null +++ b/codex-rs/app-server/src/models_refresh_worker_tests.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_models_manager::manager::ModelsEndpointClient; +use codex_models_manager::manager::ModelsEndpointFuture; +use codex_models_manager::manager::OpenAiModelsManager; +use codex_models_manager::manager::SharedModelsManager; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CoreResult; +use codex_protocol::openai_models::ModelInfo; +use pretty_assertions::assert_eq; +use tempfile::tempdir; +use tokio::sync::Notify; + +use super::*; + +#[derive(Debug)] +struct TestModelsEndpoint { + fetch_count: AtomicUsize, + fetched: Notify, + release_second_fetch: Notify, +} + +impl TestModelsEndpoint { + fn new() -> Arc { + Arc::new(Self { + fetch_count: AtomicUsize::new(0), + fetched: Notify::new(), + release_second_fetch: Notify::new(), + }) + } + + async fn wait_for_fetch_count(&self, expected: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + while self.fetch_count.load(Ordering::SeqCst) < expected { + self.fetched.notified().await; + } + }) + .await + .unwrap_or_else(|_| panic!("expected {expected} model fetches")); + } +} + +impl ModelsEndpointClient for TestModelsEndpoint { + fn has_command_auth(&self) -> bool { + true + } + + fn uses_codex_backend(&self) -> ModelsEndpointFuture<'_, bool> { + Box::pin(async { false }) + } + + fn list_models<'a>( + &'a self, + _client_version: &'a str, + ) -> ModelsEndpointFuture<'a, CoreResult<(Vec, Option)>> { + Box::pin(async move { + let fetch_index = self.fetch_count.fetch_add(1, Ordering::SeqCst); + self.fetched.notify_one(); + if fetch_index == 0 { + return Err(CodexErr::Io(std::io::Error::other("test failure"))); + } + if fetch_index == 1 { + self.release_second_fetch.notified().await; + } + Ok((Vec::new(), None)) + }) + } +} + +#[tokio::test] +async fn refreshes_immediately_periodically_and_stops_when_dropped() { + let codex_home = tempdir().expect("temp dir"); + let endpoint = TestModelsEndpoint::new(); + let models_manager: SharedModelsManager = Arc::new(OpenAiModelsManager::new( + codex_home.path().to_path_buf(), + endpoint.clone(), + /*auth_manager*/ None, + CollaborationModesConfig::default(), + )); + let worker = spawn_with_interval(&models_manager, Duration::from_millis(10)); + + endpoint.wait_for_fetch_count(/*expected*/ 2).await; + drop(worker); + endpoint.release_second_fetch.notify_one(); + tokio::time::sleep(Duration::from_millis(30)).await; + + assert_eq!(endpoint.fetch_count.load(Ordering::SeqCst), 2); +} diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 52d74b8198c2..9ce815bafeb9 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -977,6 +977,7 @@ mod tests { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: None, network_approval_context: None, command: Some("echo hi".to_string()), diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index a7e4f3d49c37..a950d4dcb766 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; @@ -284,10 +285,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; @@ -332,7 +339,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; @@ -391,9 +397,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)] @@ -507,6 +510,7 @@ pub(crate) use command_exec_processor::CommandExecRequestProcessor; pub(crate) use config_processor::ConfigRequestProcessor; pub(crate) use environment_processor::EnvironmentRequestProcessor; pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor; +pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessorArgs; pub(crate) use feedback_processor::FeedbackRequestProcessor; pub(crate) use fs_processor::FsRequestProcessor; pub(crate) use git_processor::GitRequestProcessor; @@ -542,6 +546,36 @@ fn resolve_request_cwd(cwd: Option) -> Result, .transpose() } +fn resolve_turn_environment_selections( + thread_manager: &ThreadManager, + environments: Option>, +) -> Result>, JSONRPCErrorError> { + let Some(environments) = environments else { + return Ok(None); + }; + let mut selections = Vec::with_capacity(environments.len()); + for environment in environments { + let environment_id = environment.environment_id; + let cwd = environment + .cwd + .to_inferred_path_uri() + .ok_or_else(|| { + invalid_request(format!( + "invalid cwd for environment `{environment_id}`: path `{}` does not use absolute POSIX or Windows path syntax", + environment.cwd + )) + })?; + selections.push(TurnEnvironmentSelection { + environment_id, + cwd, + }); + } + thread_manager + .validate_environment_selections(&selections) + .map_err(environment_selection_error)?; + Ok(Some(selections)) +} + fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec { let mut resolved_roots = Vec::new(); for root in workspace_roots { 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 173c2f82cbcb..c9aa3e3a2305 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, @@ -173,7 +184,7 @@ impl AccountRequestProcessor { } } - async fn maybe_refresh_remote_installed_plugins_cache_for_current_config( + async fn maybe_refresh_plugin_caches_for_current_config( config_manager: &ConfigManager, thread_manager: &Arc, auth: Option, @@ -181,6 +192,9 @@ impl AccountRequestProcessor { thread_manager .plugins_manager() .set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode)); + thread_manager + .plugins_manager() + .clear_recommended_plugins_cache(); match config_manager .load_latest_config(/*fallback_cwd*/ None) @@ -191,7 +205,7 @@ impl AccountRequestProcessor { let refresh_config_manager = config_manager.clone(); thread_manager .plugins_manager() - .maybe_start_remote_installed_plugins_cache_refresh( + .maybe_start_remote_plugin_caches_refresh( &config.plugins_config_input(), auth, Some(Arc::new(move || { @@ -216,7 +230,7 @@ impl AccountRequestProcessor { ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); + thread_manager.skills_service().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } @@ -345,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)] @@ -618,7 +633,7 @@ impl AccountRequestProcessor { } async fn send_login_success_notifications(&self, login_id: Option) { - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + Self::maybe_refresh_plugin_caches_for_current_config( &self.config_manager, &self.thread_manager, self.auth_manager.auth_cached(), @@ -671,7 +686,7 @@ impl AccountRequestProcessor { .await; let auth = auth_manager.auth_cached(); - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + Self::maybe_refresh_plugin_caches_for_current_config( &config_manager, &thread_manager, auth.clone(), @@ -703,7 +718,7 @@ impl AccountRequestProcessor { } } - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + Self::maybe_refresh_plugin_caches_for_current_config( &self.config_manager, &self.thread_manager, self.auth_manager.auth_cached(), @@ -940,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 { @@ -962,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, @@ -1012,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::*; @@ -1052,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 9c0cc1c8b517..95423402003d 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); @@ -511,7 +491,7 @@ impl CatalogRequestProcessor { let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) .await; - let skills_manager = self.thread_manager.skills_manager(); + let skills_service = self.thread_manager.skills_service(); let plugins_manager = self.thread_manager.plugins_manager(); let fs = self .thread_manager @@ -523,7 +503,7 @@ impl CatalogRequestProcessor { let config = &config; let fs = fs.clone(); let plugins_manager = &plugins_manager; - let skills_manager = &skills_manager; + let skills_service = &skills_service; async move { let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { Ok(resolved) => resolved, @@ -559,9 +539,10 @@ impl CatalogRequestProcessor { config_layer_stack, config.bundled_skills_enabled(), ); - let outcome = skills_manager - .skills_for_cwd(&skills_input, force_reload, fs) + let snapshot = skills_service + .snapshot_for_cwd(&skills_input, force_reload, fs) .await; + let outcome = snapshot.outcome(); let errors = errors_to_info(&outcome.errors); let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); ( @@ -590,7 +571,7 @@ impl CatalogRequestProcessor { self.skills_watcher .register_runtime_extra_roots(&extra_roots); self.thread_manager - .skills_manager() + .skills_service() .set_extra_roots(extra_roots); self.outgoing .send_server_notification(ServerNotification::SkillsChanged( @@ -703,7 +684,7 @@ impl CatalogRequestProcessor { .await .map(|()| { self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + self.thread_manager.skills_service().clear_cache(); SkillsConfigWriteResponse { effective_enabled: enabled, } diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 5b14f0b0c06e..dfe7555d5852 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -297,6 +297,7 @@ impl CommandExecRequestProcessor { network: started_network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy), + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop: self 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 14a6cbb1c8f0..602a25a227a8 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -170,7 +170,7 @@ impl ConfigRequestProcessor { pub(crate) async fn handle_config_mutation(&self) { self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + self.thread_manager.skills_service().clear_cache(); } async fn handle_config_mutation_result( @@ -296,15 +296,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/environment_processor.rs b/codex-rs/app-server/src/request_processors/environment_processor.rs index eb9b283f7bc2..36533aa231fc 100644 --- a/codex-rs/app-server/src/request_processors/environment_processor.rs +++ b/codex-rs/app-server/src/request_processors/environment_processor.rs @@ -1,4 +1,5 @@ use super::*; +use std::time::Duration; #[derive(Clone)] pub(crate) struct EnvironmentRequestProcessor { @@ -17,7 +18,11 @@ impl EnvironmentRequestProcessor { params: EnvironmentAddParams, ) -> Result, JSONRPCErrorError> { self.environment_manager - .upsert_environment(params.environment_id, params.exec_server_url) + .upsert_environment( + params.environment_id, + params.exec_server_url, + params.connect_timeout_ms.map(Duration::from_millis), + ) .map_err(|err| invalid_request(err.to_string()))?; Ok(Some(EnvironmentAddResponse {}.into())) } diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 29faf9a9b338..69bb4218ce8c 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -15,13 +15,19 @@ use crate::config_manager::ConfigManager; use crate::error_code::internal_error; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::ExternalAgentConfigImportCompletedInput; +use codex_analytics::ExternalAgentConfigImportFailureInput; use codex_app_server_protocol::CommandMigration; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; +use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse; +use codex_app_server_protocol::ExternalAgentConfigImportHistory; use codex_app_server_protocol::ExternalAgentConfigImportItemTypeFailure as ProtocolImportFailure; use codex_app_server_protocol::ExternalAgentConfigImportItemTypeSuccess as ProtocolImportSuccess; use codex_app_server_protocol::ExternalAgentConfigImportParams; +use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigImportTypeResult as ProtocolImportTypeResult; use codex_app_server_protocol::ExternalAgentConfigMigrationItem; @@ -35,6 +41,9 @@ use codex_app_server_protocol::ServerNotification; use codex_arg0::Arg0DispatchPaths; use codex_core::ThreadManager; use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration; +use codex_rollout::StateDbHandle; +use codex_state::ExternalAgentConfigImportFailureRecord; +use codex_state::ExternalAgentConfigImportSuccessRecord; use codex_thread_store::ThreadStore; use std::collections::HashSet; use std::path::PathBuf; @@ -50,18 +59,35 @@ pub(crate) struct ExternalAgentConfigRequestProcessor { session_importer: ExternalAgentSessionImporter, thread_manager: Arc, config_processor: ConfigRequestProcessor, + state_db: Option, + analytics_events_client: AnalyticsEventsClient, +} + +pub(crate) struct ExternalAgentConfigRequestProcessorArgs { + pub(crate) outgoing: Arc, + pub(crate) thread_manager: Arc, + pub(crate) thread_store: Arc, + pub(crate) config_manager: ConfigManager, + pub(crate) config_processor: ConfigRequestProcessor, + pub(crate) state_db: Option, + pub(crate) analytics_events_client: AnalyticsEventsClient, + pub(crate) arg0_paths: Arg0DispatchPaths, + pub(crate) codex_home: PathBuf, } impl ExternalAgentConfigRequestProcessor { - pub(crate) fn new( - outgoing: Arc, - thread_manager: Arc, - thread_store: Arc, - config_manager: ConfigManager, - config_processor: ConfigRequestProcessor, - arg0_paths: Arg0DispatchPaths, - codex_home: PathBuf, - ) -> Self { + pub(crate) fn new(args: ExternalAgentConfigRequestProcessorArgs) -> Self { + let ExternalAgentConfigRequestProcessorArgs { + outgoing, + thread_manager, + thread_store, + config_manager, + config_processor, + state_db, + analytics_events_client, + arg0_paths, + codex_home, + } = args; let session_importer = ExternalAgentSessionImporter::new( codex_home.clone(), Arc::clone(&thread_manager), @@ -75,6 +101,8 @@ impl ExternalAgentConfigRequestProcessor { session_importer, thread_manager, config_processor, + state_db, + analytics_events_client, } } @@ -178,6 +206,7 @@ impl ExternalAgentConfigRequestProcessor { params: ExternalAgentConfigImportParams, ) -> Result<(), JSONRPCErrorError> { let import_id = Uuid::new_v4().to_string(); + let analytics_source = params.source.clone().unwrap_or_default(); let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); let has_migration_items = !params.migration_items.is_empty(); let has_plugin_imports = params.migration_items.iter().any(|item| { @@ -188,7 +217,7 @@ impl ExternalAgentConfigRequestProcessor { }); let (pending_session_imports, session_validation_result) = self.validate_pending_session_imports(¶ms); - let import_outcome = self.import_external_agent_config(params).await?; + let import_outcome = self.import_external_agent_config(params).await; if needs_runtime_refresh { self.config_processor.handle_config_mutation().await; } @@ -207,23 +236,34 @@ impl ExternalAgentConfigRequestProcessor { let mut completed_item_results = Vec::new(); if let Some(session_validation_result) = session_validation_result { + send_import_progress(&self.outgoing, &import_id, &session_validation_result).await; completed_item_results.push(session_validation_result); } for item_result in import_outcome.item_results { + send_import_progress(&self.outgoing, &import_id, &item_result).await; completed_item_results.push(item_result); } let has_background_imports = !import_outcome.pending_plugin_imports.is_empty() || !pending_session_imports.is_empty(); if !has_background_imports { - send_completed_import_notification(&self.outgoing, import_id, &completed_item_results) - .await; + send_completed_import_notification( + &self.outgoing, + self.state_db.as_ref(), + &self.analytics_events_client, + import_id, + analytics_source, + &completed_item_results, + ) + .await; return Ok(()); } let session_importer = self.session_importer.clone(); let plugin_processor = self.clone(); let outgoing = Arc::clone(&self.outgoing); + let state_db = self.state_db.clone(); + let analytics_events_client = self.analytics_events_client.clone(); let thread_manager = Arc::clone(&self.thread_manager); let session_import_result = (!pending_session_imports.is_empty()).then(|| { CoreImportItemResult::new( @@ -234,13 +274,19 @@ impl ExternalAgentConfigRequestProcessor { }); let pending_plugin_imports = import_outcome.pending_plugin_imports; tokio::spawn(async move { + let session_progress_outgoing = Arc::clone(&outgoing); + let session_import_id = import_id.clone(); let session_imports = async move { let session_import_result = session_import_result?; let item_result = session_importer .import_sessions(pending_session_imports, session_import_result) .await; + send_import_progress(&session_progress_outgoing, &session_import_id, &item_result) + .await; Some(item_result) }; + let plugin_progress_outgoing = Arc::clone(&outgoing); + let plugin_import_id = import_id.clone(); let plugin_imports = async move { let mut item_results = Vec::new(); for pending_plugin_import in pending_plugin_imports { @@ -265,6 +311,12 @@ impl ExternalAgentConfigRequestProcessor { ); } } + send_import_progress( + &plugin_progress_outgoing, + &plugin_import_id, + &item_result, + ) + .await; item_results.push(item_result); } item_results @@ -278,14 +330,41 @@ impl ExternalAgentConfigRequestProcessor { completed_item_results.extend(background_item_results); if has_plugin_imports { thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); + thread_manager.skills_service().clear_cache(); } - send_completed_import_notification(&outgoing, import_id, &completed_item_results).await; + send_completed_import_notification( + &outgoing, + state_db.as_ref(), + &analytics_events_client, + import_id, + analytics_source, + &completed_item_results, + ) + .await; }); Ok(()) } + pub(crate) async fn read_import_histories( + &self, + ) -> Result { + let state_db = self + .state_db + .as_ref() + .ok_or_else(|| internal_error("state database is unavailable"))?; + let histories = state_db + .external_agent_config_import_history_records() + .await + .map_err(|err| internal_error(format!("failed to read import histories: {err}")))?; + let data = histories + .into_iter() + .map(protocol_import_history) + .collect::, _>>()?; + + Ok(ExternalAgentConfigImportHistoriesReadResponse { data }) + } + fn validate_pending_session_imports( &self, params: &ExternalAgentConfigImportParams, @@ -355,7 +434,7 @@ impl ExternalAgentConfigRequestProcessor { async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, - ) -> Result { + ) -> CoreImportOutcome { self.migration_service .import( params @@ -450,7 +529,6 @@ impl ExternalAgentConfigRequestProcessor { .collect(), ) .await - .map_err(|err| internal_error(err.to_string())) } async fn complete_pending_plugin_import( @@ -467,12 +545,41 @@ impl ExternalAgentConfigRequestProcessor { } } +async fn send_import_progress( + outgoing: &OutgoingMessageSender, + import_id: &str, + item_result: &CoreImportItemResult, +) { + outgoing + .send_server_notification(ServerNotification::ExternalAgentConfigImportProgress( + ExternalAgentConfigImportProgressNotification { + import_id: import_id.to_string(), + item_type_results: vec![protocol_import_type_result(item_result)], + }, + )) + .await; +} + async fn send_completed_import_notification( outgoing: &OutgoingMessageSender, + state_db: Option<&StateDbHandle>, + analytics_events_client: &AnalyticsEventsClient, import_id: String, + analytics_source: String, item_results: &[CoreImportItemResult], ) { let notification = completed_notification(import_id, item_results); + log_completed_import_failures(¬ification); + track_completed_import_notification(analytics_events_client, &analytics_source, ¬ification); + if let Some(state_db) = state_db + && let Err(err) = record_completed_import_notification(state_db, ¬ification).await + { + tracing::warn!( + import_id = %notification.import_id, + error = %err, + "failed to record external agent config import completion" + ); + } outgoing .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( notification, @@ -480,6 +587,172 @@ async fn send_completed_import_notification( .await; } +fn log_completed_import_failures(notification: &ExternalAgentConfigImportCompletedNotification) { + for type_result in ¬ification.item_type_results { + for failure in &type_result.failures { + let error_type = import_failure_error_type(failure); + tracing::warn!( + import_id = %notification.import_id, + item_type = ?failure.item_type, + error_type = %error_type, + failure_stage = %failure.failure_stage, + cwd = ?failure.cwd, + source = ?failure.source, + error = %failure.message, + "external agent config migration item failed" + ); + } + } +} + +fn track_completed_import_notification( + analytics_events_client: &AnalyticsEventsClient, + analytics_source: &str, + notification: &ExternalAgentConfigImportCompletedNotification, +) { + for type_result in ¬ification.item_type_results { + let item_type = analytics_migration_item_type(type_result.item_type).to_string(); + analytics_events_client.track_external_agent_config_import_completed( + ExternalAgentConfigImportCompletedInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + success_count: type_result.successes.len(), + failed_count: type_result.failures.len(), + }, + ); + for failure in &type_result.failures { + analytics_events_client.track_external_agent_config_import_failure( + ExternalAgentConfigImportFailureInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + failure_stage: failure.failure_stage.clone(), + error_type: import_failure_error_type(failure), + }, + ); + } + } +} + +fn import_failure_error_type(failure: &ProtocolImportFailure) -> String { + failure + .error_type + .clone() + .unwrap_or_else(|| failure.failure_stage.clone()) +} + +fn analytics_migration_item_type(item_type: ExternalAgentConfigMigrationItemType) -> &'static str { + match item_type { + ExternalAgentConfigMigrationItemType::AgentsMd => "AGENTS_MD", + ExternalAgentConfigMigrationItemType::Config => "CONFIG", + ExternalAgentConfigMigrationItemType::Skills => "SKILLS", + ExternalAgentConfigMigrationItemType::Plugins => "PLUGINS", + ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP_SERVER_CONFIG", + ExternalAgentConfigMigrationItemType::Subagents => "SUBAGENTS", + ExternalAgentConfigMigrationItemType::Hooks => "HOOKS", + ExternalAgentConfigMigrationItemType::Commands => "COMMANDS", + ExternalAgentConfigMigrationItemType::Sessions => "SESSIONS", + } +} + +async fn record_completed_import_notification( + state_db: &StateDbHandle, + notification: &ExternalAgentConfigImportCompletedNotification, +) -> anyhow::Result<()> { + let successes = notification + .item_type_results + .iter() + .flat_map(|type_result| type_result.successes.iter()) + .map(|success| { + Ok(ExternalAgentConfigImportSuccessRecord { + item_type: serde_json::from_value(serde_json::to_value(success.item_type)?)?, + cwd: success.cwd.clone(), + source: success.source.clone(), + target: success.target.clone(), + }) + }) + .collect::>>()?; + let failures = notification + .item_type_results + .iter() + .flat_map(|type_result| type_result.failures.iter()) + .map(|failure| { + Ok(ExternalAgentConfigImportFailureRecord { + item_type: serde_json::from_value(serde_json::to_value(failure.item_type)?)?, + error_type: failure.error_type.clone(), + failure_stage: failure.failure_stage.clone(), + message: failure.message.clone(), + cwd: failure.cwd.clone(), + source: failure.source.clone(), + }) + }) + .collect::>>()?; + state_db + .record_external_agent_config_import_completed( + notification.import_id.as_str(), + &successes, + &failures, + ) + .await +} + +fn protocol_import_history( + record: codex_state::ExternalAgentConfigImportHistoryRecord, +) -> Result { + let successes = record + .successes + .into_iter() + .map(protocol_import_success_record) + .collect::, _>>()?; + let failures = record + .failures + .into_iter() + .map(protocol_import_failure_record) + .collect::, _>>()?; + + Ok(ExternalAgentConfigImportHistory { + import_id: record.import_id, + completed_at_ms: record.completed_at_ms, + successes, + failures, + }) +} + +fn protocol_import_success_record( + record: ExternalAgentConfigImportSuccessRecord, +) -> Result { + Ok(ProtocolImportSuccess { + item_type: protocol_import_record_item_type(record.item_type)?, + cwd: record.cwd, + source: record.source, + target: record.target, + }) +} + +fn protocol_import_failure_record( + record: ExternalAgentConfigImportFailureRecord, +) -> Result { + Ok(ProtocolImportFailure { + item_type: protocol_import_record_item_type(record.item_type)?, + error_type: record.error_type, + failure_stage: record.failure_stage, + message: record.message, + cwd: record.cwd, + source: record.source, + }) +} + +fn protocol_import_record_item_type( + item_type: String, +) -> Result { + serde_json::from_value(serde_json::Value::String(item_type.clone())).map_err(|err| { + internal_error(format!( + "failed to decode import item type {item_type}: {err}" + )) + }) +} + fn completed_notification( import_id: String, item_results: &[CoreImportItemResult], @@ -529,6 +802,22 @@ fn completed_notification( } } +fn protocol_import_type_result(item_result: &CoreImportItemResult) -> ProtocolImportTypeResult { + ProtocolImportTypeResult { + item_type: protocol_migration_item_type(item_result.item_type), + successes: item_result + .successes + .iter() + .map(protocol_import_success) + .collect(), + failures: item_result + .raw_errors + .iter() + .map(protocol_import_raw_error) + .collect(), + } +} + fn protocol_import_success( success: &crate::config::external_agent_config::ExternalAgentConfigImportSuccess, ) -> ProtocolImportSuccess { @@ -543,6 +832,7 @@ fn protocol_import_success( fn protocol_import_raw_error(raw_error: &CoreImportRawError) -> ProtocolImportFailure { ProtocolImportFailure { item_type: protocol_migration_item_type(raw_error.item_type), + error_type: raw_error.error_type.clone(), failure_stage: raw_error.failure_stage.clone(), message: raw_error.message.clone(), cwd: raw_error.cwd.clone(), 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 d4abb155ec86..0e17cb5737c9 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/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index a40007db115a..cfdad27f50ff 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -67,17 +67,13 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = - match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities.request_attestation, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, false, Vec::new()), - }; + let capabilities = params.capabilities.unwrap_or_default(); + let experimental_api_enabled = capabilities.experimental_api; + let request_attestation = capabilities.request_attestation; + let supports_openai_form_elicitation = capabilities.mcp_server_openai_form_elicitation; + let opt_out_notification_methods = capabilities + .opt_out_notification_methods + .unwrap_or_default(); let ClientInfo { name, title: _title, @@ -101,6 +97,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, + supports_openai_form_elicitation, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 609d89459a6d..1e4716a88d45 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -18,9 +18,11 @@ use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETP use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; +use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; +use codex_plugin::PluginId; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -474,7 +476,7 @@ impl PluginRequestProcessor { ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); + thread_manager.skills_service().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } @@ -484,7 +486,7 @@ impl PluginRequestProcessor { fn clear_plugin_related_caches(&self) { self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + self.thread_manager.skills_service().clear_cache(); } async fn load_latest_config( @@ -1437,15 +1439,24 @@ impl PluginRequestProcessor { } let plugins_manager = self.thread_manager.plugins_manager(); + let marketplace_display = marketplace_path.display().to_string(); + let plugin_name_for_log = plugin_name.clone(); let request = PluginInstallRequest { plugin_name, marketplace_path, }; - let result = plugins_manager - .install_plugin(request) - .await - .map_err(Self::plugin_install_error)?; + let result = match plugins_manager.install_plugin(request).await { + Ok(result) => result, + Err(err) => { + warn!( + marketplace = %marketplace_display, + plugin_name = %plugin_name_for_log, + "failed to install plugin: {err}" + ); + return Err(Self::plugin_install_error(err)); + } + }; let config = match self.load_latest_config(config_cwd).await { Ok(config) => config, Err(err) => { @@ -1512,11 +1523,20 @@ impl PluginRequestProcessor { ) .await .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &remote_marketplace_name, + error_type, + err.to_string(), + ); remote_plugin_catalog_error_to_jsonrpc( err, "read remote plugin details before install", ) })?; + let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); + let remote_plugin_name = remote_detail.summary.name.clone(); if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { return Err(invalid_request(format!( "remote plugin {remote_plugin_id} is disabled by admin" @@ -1527,31 +1547,48 @@ impl PluginRequestProcessor { "remote plugin {remote_plugin_id} is not available for install" ))); } - let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); // Direct install writes the same cache tree that installed-plugin sync // prunes before the backend installed snapshot can include this plugin. let _remote_plugin_cache_mutation = codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight( config.codex_home.as_path(), &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, ); let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( &remote_plugin_id, &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, remote_detail.release_version.as_deref(), remote_detail.bundle_download_url.as_deref(), remote_detail.app_manifest.clone(), ) - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( config.codex_home.to_path_buf(), validated_bundle, ) .await - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; // Cache first so a backend install cannot succeed when local materialization fails. // If this backend call fails, the cache entry is harmless because remote installed state @@ -1563,7 +1600,16 @@ impl PluginRequestProcessor { &remote_plugin_id, ) .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin") + })?; self.thread_manager .plugins_manager() @@ -1573,9 +1619,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); @@ -1646,6 +1697,34 @@ impl PluginRequestProcessor { }) } + fn track_plugin_install_failed_for_remote_plugin( + &self, + remote_plugin_id: &str, + marketplace_name: &str, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + remote_plugin_id = %remote_plugin_id, + marketplace_name = %marketplace_name, + error_type = %error_type, + error = %error_message, + "remote plugin install failed" + ); + // The remote id is reported separately; this local name only satisfies + // PluginId validation before remote details are available. + let Ok(plugin_id) = PluginId::new("unknown".to_string(), marketplace_name.to_string()) + else { + return; + }; + 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()); + } + async fn plugin_apps_needing_auth_for_install( &self, config: &Config, @@ -2145,6 +2224,75 @@ fn remote_plugin_detail_to_info( } } +fn remote_plugin_catalog_error_type(err: &RemotePluginCatalogError) -> &'static str { + match err { + RemotePluginCatalogError::AuthRequired => "remote_catalog_auth_required", + RemotePluginCatalogError::UnsupportedAuthMode => "remote_catalog_unsupported_auth_mode", + RemotePluginCatalogError::AuthToken(_) => "remote_catalog_auth_token", + RemotePluginCatalogError::Request { .. } => "remote_catalog_request", + RemotePluginCatalogError::UnexpectedStatus { .. } => "remote_catalog_unexpected_status", + RemotePluginCatalogError::Decode { .. } => "remote_catalog_decode", + RemotePluginCatalogError::InvalidBaseUrl(_) => "remote_catalog_invalid_base_url", + RemotePluginCatalogError::InvalidBaseUrlPath => "remote_catalog_invalid_base_url_path", + RemotePluginCatalogError::UnknownMarketplace { .. } => "remote_catalog_unknown_marketplace", + RemotePluginCatalogError::UnexpectedPluginId { .. } => { + "remote_catalog_unexpected_plugin_id" + } + RemotePluginCatalogError::UnexpectedSkillName { .. } => { + "remote_catalog_unexpected_skill_name" + } + RemotePluginCatalogError::UnexpectedEnabledState { .. } => { + "remote_catalog_unexpected_enabled_state" + } + RemotePluginCatalogError::InvalidPluginPath { .. } => "remote_catalog_invalid_plugin_path", + RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. } => { + "remote_catalog_plugin_share_checkout_not_available" + } + RemotePluginCatalogError::Archive { .. } => "remote_catalog_archive", + RemotePluginCatalogError::ArchiveJoin(_) => "remote_catalog_archive_join", + RemotePluginCatalogError::ArchiveTooLarge { .. } => "remote_catalog_archive_too_large", + RemotePluginCatalogError::MissingUploadEtag => "remote_catalog_missing_upload_etag", + RemotePluginCatalogError::UnexpectedResponse(_) => "remote_catalog_unexpected_response", + RemotePluginCatalogError::CacheRemove(_) => "remote_catalog_cache_remove", + } +} + +fn remote_plugin_bundle_install_error_type(err: &RemotePluginBundleInstallError) -> &'static str { + match err { + RemotePluginBundleInstallError::MissingReleaseVersion { .. } => { + "remote_bundle_missing_release_version" + } + RemotePluginBundleInstallError::InvalidReleaseVersion { .. } => { + "remote_bundle_invalid_release_version" + } + RemotePluginBundleInstallError::MissingBundleDownloadUrl { .. } => { + "remote_bundle_missing_download_url" + } + RemotePluginBundleInstallError::InvalidBundleDownloadUrl { .. } => { + "remote_bundle_invalid_download_url" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { .. } => { + "remote_bundle_unsupported_download_url_scheme" + } + RemotePluginBundleInstallError::InvalidPluginId { .. } => "remote_bundle_invalid_plugin_id", + RemotePluginBundleInstallError::DownloadRequest { .. } => "remote_bundle_download_request", + RemotePluginBundleInstallError::DownloadStatus { .. } => "remote_bundle_download_status", + RemotePluginBundleInstallError::DownloadBody { .. } => "remote_bundle_download_body", + RemotePluginBundleInstallError::DownloadTooLarge { .. } => { + "remote_bundle_download_too_large" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { .. } => { + "remote_bundle_unsupported_download_final_url" + } + RemotePluginBundleInstallError::ExtractedBundleTooLarge { .. } => { + "remote_bundle_extracted_too_large" + } + RemotePluginBundleInstallError::Io { .. } => "remote_bundle_io", + RemotePluginBundleInstallError::InvalidBundle(_) => "remote_bundle_invalid_bundle", + RemotePluginBundleInstallError::Store(_) => "remote_bundle_store", + } +} + fn remote_plugin_catalog_error_to_jsonrpc( err: RemotePluginCatalogError, context: &str, diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 2f40ce6602f3..5878047237ad 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -135,6 +135,21 @@ impl ThreadGoalRequestProcessor { .await .map_err(goal_service_error)?; let goal = ThreadGoal::from(outcome.goal.clone()); + + let persist_result = match self.thread_manager.get_thread(thread_id).await { + Ok(thread) => { + // Live goal-first threads can be listed before any user turn is written. + // Use the live path so JSONL and SQLite preview metadata stay in sync. + thread + .append_rollout_items(&[outcome.thread_goal_updated_item()]) + .await + } + Err(_) => Ok(()), + }; + if let Err(err) = persist_result { + warn!("failed to persist goal update for live thread {thread_id}: {err}"); + } + self.outgoing .send_response( request_id.clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index fd9e93e1894c..21adf4613476 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -639,6 +639,7 @@ pub(super) async fn handle_pending_thread_resume_request( active_permission_profile, workspace_roots, reasoning_effort, + multi_agent_mode, .. } = config_snapshot; let instruction_sources = pending.instruction_sources; @@ -661,6 +662,7 @@ pub(super) async fn handle_pending_thread_resume_request( sandbox, active_permission_profile, reasoning_effort, + multi_agent_mode, initial_turns_page, }; outgoing.send_response(request_id, response).await; 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 671037b9aef0..565bee2b2faa 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2,9 +2,9 @@ use super::*; use crate::error_code::method_not_found; use codex_app_server_protocol::SelectedCapabilityRoot; use codex_extension_api::ExtensionDataInit; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; -use codex_utils_path_uri::PathUri; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; @@ -418,6 +418,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -425,6 +426,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, request_context, ) .await @@ -447,12 +449,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, ) .await .map(|()| None) @@ -464,12 +468,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, ) .await .map(|()| None) @@ -885,6 +891,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -906,6 +913,7 @@ impl ThreadRequestProcessor { mock_experimental_field: _mock_experimental_field, experimental_raw_events, personality, + multi_agent_mode, ephemeral, session_start_source, thread_source, @@ -916,7 +924,8 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } - let environment_selections = self.parse_environment_selections(environments)?; + let environment_selections = + resolve_turn_environment_selections(self.thread_manager.as_ref(), environments)?; let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, @@ -955,8 +964,10 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, config, typesafe_overrides, + multi_agent_mode, dynamic_tools, selected_capability_roots.unwrap_or_default(), session_start_source, @@ -1028,8 +1039,10 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, config_overrides: Option>, typesafe_overrides: ConfigOverrides, + multi_agent_mode: Option, dynamic_tools: Option>, selected_capability_roots: Vec, session_start_source: Option, @@ -1153,9 +1166,11 @@ impl ThreadRequestProcessor { thread_source, dynamic_tools, metrics_service_name: service_name, + multi_agent_mode, parent_trace: request_trace, environments, thread_extension_init, + supports_openai_form_elicitation, }) .instrument(tracing::info_span!( "app_server.thread_start.create_thread", @@ -1181,7 +1196,7 @@ impl ThreadRequestProcessor { ) .await?; - let instruction_sources = thread.instruction_sources().await; + let instruction_sources = thread.legacy_instruction_sources().await; let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -1257,6 +1272,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, }; let notif = thread_started_notification(thread); listener_task_context @@ -1321,27 +1337,6 @@ impl ThreadRequestProcessor { } } - fn parse_environment_selections( - &self, - environments: Option>, - ) -> Result>, JSONRPCErrorError> { - let environment_selections = environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: PathUri::from_abs_path(&environment.cwd), - }) - .collect::>() - }); - if let Some(environment_selections) = environment_selections.as_ref() { - self.thread_manager - .validate_environment_selections(environment_selections) - .map_err(environment_selection_error)?; - } - Ok(environment_selections) - } - async fn thread_archive_inner( &self, params: ThreadArchiveParams, @@ -1812,16 +1807,22 @@ impl ThreadRequestProcessor { .list_background_terminals() .await .into_iter() - .map(|terminal| ThreadBackgroundTerminal { - item_id: terminal.item_id, - process_id: terminal.process_id, - command: terminal.command, - cwd: terminal.cwd, - os_pid: None, - cpu_percent: None, - rss_kb: None, + .map(|terminal| { + // TODO(anp): Migrate ThreadBackgroundTerminal to PathUri. + let cwd = terminal.cwd.to_abs_path().map_err(|err| { + internal_error(format!("background terminal has invalid cwd: {err}")) + })?; + Ok(ThreadBackgroundTerminal { + item_id: terminal.item_id, + process_id: terminal.process_id, + command: terminal.command, + cwd, + os_pid: None, + cpu_percent: None, + rss_kb: None, + }) }) - .collect::>(); + .collect::, JSONRPCErrorError>>()?; let (data, next_cursor) = paginate_background_terminals(&terminals, cursor, limit)?; @@ -1967,6 +1968,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (stored_threads, next_cursor) = self @@ -2048,6 +2050,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds); @@ -2572,6 +2575,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2716,6 +2720,7 @@ impl ThreadRequestProcessor { thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, + supports_openai_form_elicitation, ) .await { @@ -2735,7 +2740,7 @@ impl ThreadRequestProcessor { self.outgoing.send_error(request_id, err).await; return Ok(()); } - let instruction_sources = codex_thread.instruction_sources().await; + let instruction_sources = codex_thread.legacy_instruction_sources().await; let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2841,6 +2846,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, initial_turns_page, }; @@ -3043,7 +3049,7 @@ impl ThreadRequestProcessor { /*include_turns*/ false, ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); - let instruction_sources = existing_thread.instruction_sources().await; + let instruction_sources = existing_thread.legacy_instruction_sources().await; let listener_command_tx = { let thread_state = thread_state.lock().await; @@ -3333,6 +3339,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3442,6 +3449,7 @@ impl ThreadRequestProcessor { }), thread_source.map(Into::into), self.request_trace_context(&request_id).await, + supports_openai_form_elicitation, ) .await .map_err(|err| match err { @@ -3474,7 +3482,7 @@ impl ThreadRequestProcessor { .map_err(|err| core_thread_write_error("inherit source thread name", err))?; } - let instruction_sources = forked_thread.instruction_sources().await; + let instruction_sources = forked_thread.legacy_instruction_sources().await; // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( @@ -3560,6 +3568,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, }; let notif = thread_started_notification(thread); @@ -3771,6 +3780,7 @@ fn thread_backwards_cursor_for_sort_key( let timestamp = match sort_key { StoreThreadSortKey::CreatedAt => thread.created_at, StoreThreadSortKey::UpdatedAt => thread.updated_at, + StoreThreadSortKey::RecencyAt => thread.recency_at, }; // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one // millisecond so the opposite-direction query includes the page anchor. @@ -4215,6 +4225,7 @@ pub(crate) fn thread_from_stored_thread( }, created_at: thread.created_at.timestamp(), updated_at: thread.updated_at.timestamp(), + recency_at: Some(thread.recency_at.timestamp()), status: ThreadStatus::NotLoaded, path, cwd, @@ -4420,6 +4431,7 @@ fn build_thread_from_snapshot( model_provider: config_snapshot.model_provider_id.clone(), created_at: now, updated_at: now, + recency_at: Some(now), status: ThreadStatus::NotLoaded, path, cwd: config_snapshot.cwd().clone(), 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 42711e857012..1f5ae12f4498 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 @@ -478,6 +478,7 @@ mod thread_processor_behavior_tests { reasoning_effort: None, created_at: created_at.with_timezone(&Utc), updated_at: updated_at.with_timezone(&Utc), + recency_at: updated_at.with_timezone(&Utc), archived_at: None, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), @@ -775,6 +776,7 @@ mod thread_processor_behavior_tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), session_source: SessionSource::Cli, forked_from_thread_id: None, parent_thread_id: None, @@ -1002,6 +1004,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, @@ -1058,6 +1061,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 { @@ -1108,6 +1112,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(), @@ -1380,6 +1385,31 @@ mod thread_processor_behavior_tests { Ok(()) } + #[tokio::test] + async fn wait_for_thread_subscriber_unblocks_after_connection_attaches() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ba62fd70-2ec2-4b1b-9d94-355694332dd2")?; + let connection = ConnectionId(1); + manager + .connection_initialized(connection, ConnectionCapabilities::default()) + .await; + + let wait_for_subscriber = manager.wait_for_thread_subscriber(thread_id); + let attach_connection = async { + tokio::task::yield_now().await; + manager + .try_add_connection_to_thread(thread_id, connection) + .await + }; + let ((), attached) = tokio::time::timeout(Duration::from_secs(1), async { + tokio::join!(wait_for_subscriber, attach_connection) + }) + .await?; + + assert!(attached); + Ok(()) + } + #[tokio::test] async fn closed_connection_cannot_be_reintroduced_by_auto_subscribe() -> Result<()> { let manager = ThreadStateManager::new(); diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index e970a88a9fde..b7d864069529 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -52,6 +52,7 @@ fn redacted_mcp_tool_call_result() -> McpToolCallResult { #[cfg(test)] mod tests { use super::*; + use codex_app_server_protocol::McpToolCallAppContext; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::SessionSource; @@ -78,6 +79,11 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Completed, arguments: serde_json::json!({"secret":"argument"}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), plugin_id: Some("sample@test".to_string()), result: Some(Box::new(McpToolCallResult { @@ -120,6 +126,11 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Completed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), plugin_id: Some("sample@test".to_string()), result: Some(Box::new(redacted_mcp_tool_call_result())), @@ -137,6 +148,7 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Failed, arguments: serde_json::json!({"secret":"argument"}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -156,6 +168,7 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Failed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -178,6 +191,7 @@ mod tests { model_provider: "mock_provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 0fb1320055c1..bb2c6708cdc2 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -1,5 +1,4 @@ use super::*; - #[cfg(test)] use chrono::DateTime; #[cfg(test)] @@ -206,6 +205,7 @@ pub(crate) fn thread_settings_from_config_snapshot( effort: config_snapshot.reasoning_effort.clone(), summary: config_snapshot.reasoning_summary, collaboration_mode: config_snapshot.collaboration_mode.clone(), + multi_agent_mode: config_snapshot.multi_agent_mode, personality: config_snapshot.personality, } } @@ -226,6 +226,7 @@ pub(crate) fn thread_settings_from_core_snapshot( reasoning_summary, personality, collaboration_mode, + multi_agent_mode, } = snapshot; let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); ThreadSettings { @@ -242,6 +243,7 @@ pub(crate) fn thread_settings_from_core_snapshot( effort: reasoning_effort, summary: reasoning_summary, collaboration_mode, + multi_agent_mode, personality, } } @@ -320,6 +322,7 @@ pub(crate) fn summary_to_thread( model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), + recency_at: updated_at.map(|dt| dt.timestamp()), status: ThreadStatus::NotLoaded, path: (!path.as_os_str().is_empty()).then_some(path), cwd, 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 f8902e132d54..8bead5529b12 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 86c33edb5b84..34b76a4b739a 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,10 +1,10 @@ use super::*; +use codex_protocol::config_types::MultiAgentMode; 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 codex_utils_path_uri::PathUri; const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str = "direct app-server input is not allowed for multi-agent v2 sub-agents"; @@ -61,6 +61,7 @@ struct ThreadSettingsBuildParams { effort: Option, summary: Option, collaboration_mode: Option, + multi_agent_mode: Option, personality: Option, } @@ -102,12 +103,14 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.turn_start_inner( request_id, params, app_server_client_name, app_server_client_version, + /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, ) .await .map(|response| Some(response.into())) @@ -343,27 +346,6 @@ impl TurnRequestProcessor { Ok((review_request, hint)) } - fn parse_environment_selections( - &self, - environments: Option>, - ) -> Result>, JSONRPCErrorError> { - let environment_selections = environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: PathUri::from_abs_path(&environment.cwd), - }) - .collect::>() - }); - if let Some(environment_selections) = environment_selections.as_ref() { - self.thread_manager - .validate_environment_selections(environment_selections) - .map_err(environment_selection_error)?; - } - Ok(environment_selections) - } - async fn request_trace_context( &self, request_id: &ConnectionRequestId, @@ -408,6 +390,7 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result { let (thread_id, thread) = self.load_thread(¶ms.thread_id) @@ -434,8 +417,17 @@ impl TurnRequestProcessor { .inspect_err(|error| { self.track_error_response(&request_id, error, /*error_type*/ None); })?; + thread + .set_openai_form_elicitation_support(supports_openai_form_elicitation) + .await + .map_err(|err| { + internal_error(format!( + "failed to update OpenAI form elicitation support: {err}" + )) + })?; - let environment_selections = self.parse_environment_selections(params.environments)?; + let environment_selections = + resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; // Map v2 input items to core input items. let mapped_items: Vec = params @@ -466,6 +458,7 @@ impl TurnRequestProcessor { effort: params.effort, summary: params.summary, collaboration_mode: params.collaboration_mode, + multi_agent_mode: params.multi_agent_mode, personality: params.personality, }, ) @@ -572,6 +565,7 @@ impl TurnRequestProcessor { effort, summary, collaboration_mode, + multi_agent_mode, personality, } = params; @@ -605,6 +599,7 @@ impl TurnRequestProcessor { || effort.is_some() || summary.is_some() || collaboration_mode.is_some() + || multi_agent_mode.is_some() || personality.is_some(); let runtime_workspace_roots = @@ -681,6 +676,7 @@ impl TurnRequestProcessor { summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), + multi_agent_mode, personality, }) .await @@ -704,6 +700,7 @@ impl TurnRequestProcessor { summary, service_tier, collaboration_mode, + multi_agent_mode, personality, }) } @@ -734,6 +731,7 @@ impl TurnRequestProcessor { effort: params.effort, summary: params.summary, collaboration_mode: params.collaboration_mode, + multi_agent_mode: params.multi_agent_mode, personality: params.personality, }, ) @@ -953,9 +951,10 @@ impl TurnRequestProcessor { request_id, thread.as_ref(), Op::RealtimeConversationStart(ConversationStartParams { - architecture: params.architecture, + client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false), codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false), codex_response_item_prefix: params.codex_response_item_prefix, + codex_response_handoff_prefix: params.codex_response_handoff_prefix, model: params.model, output_modality: params.output_modality, include_startup_context: params.include_startup_context.unwrap_or(true), @@ -1185,6 +1184,7 @@ impl TurnRequestProcessor { }), /*thread_source*/ None, self.request_trace_context(request_id).await, + /*supports_openai_form_elicitation*/ false, ) .await .map_err(|err| { diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs index 57edb89c7ff9..637fe963ea98 100644 --- a/codex-rs/app-server/src/skills_watcher.rs +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -8,7 +8,7 @@ use codex_app_server_protocol::SkillsChangedNotification; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::skills::SkillsLoadInput; -use codex_core::skills::SkillsManager; +use codex_core::skills::SkillsService; use codex_file_watcher::FileWatcher; use codex_file_watcher::FileWatcherSubscriber; use codex_file_watcher::Receiver; @@ -35,7 +35,7 @@ pub(crate) struct SkillsWatcher { impl SkillsWatcher { pub(crate) fn new( - skills_manager: Arc, + skills_service: Arc, outgoing: Arc, ) -> Arc { let file_watcher = match FileWatcher::new() { @@ -48,7 +48,7 @@ impl SkillsWatcher { let (subscriber, rx) = file_watcher.add_subscriber(); let shutdown_token = CancellationToken::new(); let shutdown_drop_guard = shutdown_token.clone().drop_guard(); - Self::spawn_event_loop(rx, skills_manager, outgoing, shutdown_token.child_token()); + Self::spawn_event_loop(rx, skills_service, outgoing, shutdown_token.child_token()); Arc::new(Self { subscriber, runtime_extra_roots_registration: Mutex::new(WatchRegistration::default()), @@ -110,10 +110,12 @@ impl SkillsWatcher { config.bundled_skills_enabled(), ); let roots = thread_manager - .skills_manager() + .skills_service() .skill_roots_for_config(&skills_input, Some(environment.get_filesystem())) .await .into_iter() + // Plugin roots are invalidated by plugin lifecycle operations. + .filter(|root| root.plugin_id.is_none()) .map(|root| WatchPath { path: root.path.into_path_buf(), recursive: true, @@ -124,7 +126,7 @@ impl SkillsWatcher { fn spawn_event_loop( rx: Receiver, - skills_manager: Arc, + skills_service: Arc, outgoing: Arc, shutdown_token: CancellationToken, ) { @@ -142,7 +144,7 @@ impl SkillsWatcher { if event.is_none() { break; } - skills_manager.clear_cache(); + skills_service.clear_cache(); outgoing .send_server_notification(ServerNotification::SkillsChanged( SkillsChangedNotification {}, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 6d2b48a4c88b..e4e4e42adfe2 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -13,7 +13,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_rollout::state_db::StateDbHandle; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; @@ -31,7 +31,7 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, pub(crate) history_items: Vec, pub(crate) config_snapshot: ThreadConfigSnapshot, - pub(crate) instruction_sources: Vec, + pub(crate) instruction_sources: Vec, pub(crate) thread_summary: codex_app_server_protocol::Thread, pub(crate) emit_thread_goal_update: bool, pub(crate) thread_goal_state_db: Option, @@ -200,6 +200,7 @@ mod tests { use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[test] @@ -240,6 +241,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, } } @@ -329,6 +331,23 @@ impl ThreadStateManager { .min_by_key(|connection_id| connection_id.0) } + pub(crate) async fn wait_for_thread_subscriber(&self, thread_id: ThreadId) { + let mut has_connections = { + let mut state = self.state.lock().await; + state + .threads + .entry(thread_id) + .or_default() + .has_connections_watcher + .subscribe() + }; + while !*has_connections.borrow_and_update() { + if has_connections.changed().await.is_err() { + break; + } + } + } + pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec { let state = self.state.lock().await; state diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 6d66a32d6aaf..a5997a58f5d7 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -897,6 +897,7 @@ mod tests { model_provider: "mock-provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::NotLoaded, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 4b9b387aaa78..439284a17649 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -250,10 +250,11 @@ async fn command_execution_request_approval_strips_additional_permissions_withou item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), - cwd: Some(absolute_path("/tmp")), + cwd: Some(absolute_path("/tmp").into()), command_actions: None, additional_permissions: Some( codex_app_server_protocol::AdditionalPermissionProfile { @@ -315,10 +316,11 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + environment_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), - cwd: Some(absolute_path("/tmp")), + cwd: Some(absolute_path("/tmp").into()), command_actions: None, additional_permissions: Some( codex_app_server_protocol::AdditionalPermissionProfile { diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6d446e1c423e..7b389e8fc19f 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 6e88866c7d4a..e49e841f65eb 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 6ad9f1c633a7..17f73db3ed68 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 60c31352ffc1..8fb290097a19 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -37,6 +37,8 @@ use codex_login::AuthKeyringBackendKind; use codex_login::CLIENT_ID_OVERRIDE_ENV_VAR; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::login_with_api_key; +use codex_login::login_with_bedrock_api_key; +use codex_protocol::account::AmazonBedrockCredentialSource; use codex_protocol::account::PlanType as AccountPlanType; use core_test_support::responses; use pretty_assertions::assert_eq; @@ -321,7 +323,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, @@ -389,7 +391,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, @@ -1728,13 +1730,60 @@ region = "us-west-2" let received: GetAccountResponse = to_response(resp)?; let expected = GetAccountResponse { - account: Some(Account::AmazonBedrock {}), + account: Some(Account::AmazonBedrock { + credential_source: AmazonBedrockCredentialSource::AwsManaged, + }), requires_openai_auth: false, }; assert_eq!(received, expected); Ok(()) } +#[tokio::test] +async fn get_account_with_managed_bedrock_provider() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + model_provider_id: Some("amazon-bedrock".to_string()), + ..Default::default() + }, + )?; + login_with_bedrock_api_key( + codex_home.path(), + "managed-bedrock-api-key", + "us-west-2", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + assert_eq!( + received, + GetAccountResponse { + account: Some(Account::AmazonBedrock { + credential_source: AmazonBedrockCredentialSource::CodexManaged, + }), + requires_openai_auth: false, + } + ); + Ok(()) +} + #[tokio::test] async fn get_account_with_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; @@ -1771,7 +1820,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, @@ -1780,6 +1829,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()?; @@ -1897,7 +1991,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/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index b711867b0ad5..daface3447a2 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -365,10 +365,11 @@ connectors = false #[tokio::test] async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { + let connector_id = "connector_2b0a9009c9c64bf9933a3dae3f2b1254"; let connectors = vec![AppInfo { - id: "beta".to_string(), - name: "Beta".to_string(), - description: Some("Beta connector".to_string()), + id: connector_id.to_string(), + name: "Formerly Blocked".to_string(), + description: Some("Formerly blocked connector".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, @@ -380,7 +381,7 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { is_enabled: true, plugin_display_names: Vec::new(), }]; - let mut app_only_tool = connector_tool("beta", "Beta App")?; + let mut app_only_tool = connector_tool(connector_id, "Formerly Blocked")?; app_only_tool .meta .as_mut() @@ -421,7 +422,7 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { let AppsListResponse { data, next_cursor } = to_response(response)?; assert_eq!(data.len(), 1); - assert_eq!(data[0].id, "beta"); + assert_eq!(data[0].id, connector_id); assert!(data[0].is_accessible); assert!(next_cursor.is_none()); diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 567f37397a9a..ea567a2d43cb 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -4,6 +4,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use codex_app_server_protocol::AttestationGenerateResponse; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeCapabilities; @@ -36,36 +37,26 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand { skip_if_no_network!(Ok(())); - let websocket_server = start_websocket_server_with_headers(vec![ - // App-server refreshes `/models` over HTTP during thread startup. It points at the same - // local test base URL, so let that non-websocket probe consume one connection before the - // websocket handshake under test arrives. - WebSocketConnectionConfig { - requests: Vec::new(), - response_headers: Vec::new(), - accept_delay: None, - close_after_requests: true, - }, - WebSocketConnectionConfig { - requests: vec![ - vec![ - responses::ev_response_created("warm-1"), - responses::ev_completed("warm-1"), - ], - vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ], + let websocket_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![ + vec![ + responses::ev_response_created("warm-1"), + responses::ev_completed("warm-1"), ], - response_headers: Vec::new(), - accept_delay: None, - close_after_requests: true, - }, - ]) + vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ], + ], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: true, + }]) .await; let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; create_chatgpt_websocket_config( codex_home.path(), &websocket_server.uri().replacen("ws://", "http://", 1), @@ -90,6 +81,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand experimental_api: true, request_attestation: true, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ), ) 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 6b95c036d0bc..ffe7a53627fe 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 865330be58a1..bca34d360dac 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -134,11 +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/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index bfb7d817204f..81ef145ef0c6 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -363,6 +363,7 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps._default] approvals_reviewer = "auto_review" +default_tools_approval_mode = "approve" [apps.app1] enabled = false @@ -402,6 +403,7 @@ default_tools_approval_mode = "prompt" approvals_reviewer: Some(ApprovalsReviewer::AutoReview), destructive_enabled: true, open_world_enabled: true, + default_tools_approval_mode: Some(AppToolApproval::Approve), }), apps: std::collections::HashMap::from([( "app1".to_string(), @@ -427,6 +429,16 @@ default_tools_approval_mode = "prompt" profile: None, } ); + assert_eq!( + origins + .get("apps._default.default_tools_approval_mode") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + profile: None, + } + ); assert_eq!( origins.get("apps.app1.enabled").expect("origin").name, ConfigLayerSource::User { diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 0e00bc630951..648d60f3b594 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -706,7 +706,7 @@ pub(super) async fn send_request( send_jsonrpc(stream, message).await } -async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> { +pub(super) async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> { let payload = serde_json::to_string(&message)?; stream .send(WebSocketMessage::Text(payload.into())) diff --git a/codex-rs/app-server/tests/suite/v2/current_time.rs b/codex-rs/app-server/tests/suite/v2/current_time.rs new file mode 100644 index 000000000000..74e734ae3996 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/current_time.rs @@ -0,0 +1,130 @@ +use std::path::Path; + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::CurrentTimeReadResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +#[cfg(windows)] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const CURRENT_TIME_AT: i64 = 1_781_717_655; +const CURRENT_TIME_REMINDER: &str = "It is 2026-06-17 17:34:15 UTC."; + +#[tokio::test] +async fn current_time_read_round_trip_adds_reminder_to_model_input() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + create_final_assistant_message_sse_response("Done")?, + ) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let thread_request_id = app_server + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; + + let turn_request_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "What time is it?".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + + let server_request = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CurrentTimeRead { request_id, params } = server_request else { + panic!("expected CurrentTimeRead request, got: {server_request:?}"); + }; + assert_eq!(params.thread_id, thread.id); + app_server + .send_response( + request_id, + serde_json::to_value(CurrentTimeReadResponse { + current_time_at: CURRENT_TIME_AT, + })?, + ) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert!( + response_mock + .single_request() + .message_input_texts("developer") + .iter() + .any(|text| text == CURRENT_TIME_REMINDER) + ); + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "mock_provider" + +[features.current_time_reminder] +enabled = true +reminder_interval_model_requests = 1 +clock_source = "external" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index ef1cf86753c5..2adc2be0b29f 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,8 @@ use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + // 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))] @@ -650,7 +652,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(), }, ]; let content_items = response_content_items @@ -695,7 +697,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 +723,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" } ]) diff --git a/codex-rs/app-server/tests/suite/v2/environment_add.rs b/codex-rs/app-server/tests/suite/v2/environment_add.rs new file mode 100644 index 000000000000..bf44b2760786 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/environment_add.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use codex_app_server_protocol::EnvironmentAddResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use serde_json::json; +use tempfile::TempDir; +use tokio::io::AsyncReadExt; +use tokio::net::TcpListener; +use tokio::time::timeout; + +const RPC_TIMEOUT: Duration = Duration::from_secs(10); +const CONNECTION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test] +async fn environment_add_applies_connect_timeout() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let exec_server_url = format!("ws://{}", listener.local_addr()?); + let stalled_server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await?; + let mut request = Vec::new(); + socket.read_to_end(&mut request).await?; + anyhow::ensure!(!request.is_empty(), "expected a WebSocket handshake"); + Ok::<_, anyhow::Error>(()) + }); + let codex_home = TempDir::new()?; + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(RPC_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_raw_request( + "environment/add", + Some(json!({ + "environmentId": "remote-a", + "execServerUrl": exec_server_url, + "connectTimeoutMs": 1_000, + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + RPC_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: EnvironmentAddResponse = to_response(response)?; + + timeout(CONNECTION_CLOSE_TIMEOUT, stalled_server).await???; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index b12bd43b439e..d9d3469936e7 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -39,6 +39,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -70,6 +71,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -79,9 +81,10 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -114,6 +117,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -148,6 +152,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -182,6 +187,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -191,9 +197,10 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -230,6 +237,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -268,6 +276,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -305,6 +314,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 8b165a11e4e2..a5d918cbaf0d 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -1,15 +1,19 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::start_analytics_events_server; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; +use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse; +use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -23,16 +27,25 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; +use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; #[cfg(unix)] use tokio::io::AsyncWriteExt; use tokio::time::timeout; +use super::analytics::wait_for_analytics_event; + const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +fn external_agent_home(codex_home: &Path) -> PathBuf { + codex_home.join(concat!(".", "cl", "aude")) +} + fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String { assert!(!response.import_id.is_empty()); response.import_id @@ -42,10 +55,17 @@ fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String async fn external_agent_config_import_sends_completion_notification_for_sync_only_import() -> Result<()> { let codex_home = TempDir::new()?; + let sqlite_home = TempDir::new()?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let sqlite_home_dir = sqlite_home.path().display().to_string(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_dir.as_str())), + ("CODEX_SQLITE_HOME", Some(sqlite_home_dir.as_str())), + ], + ) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -68,6 +88,21 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl .await??; let response: ExternalAgentConfigImportResponse = to_response(response)?; let import_id = assert_import_response(response); + let progress = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/progress"), + ) + .await??; + assert_eq!(progress.method, "externalAgentConfig/import/progress"); + let progress: ExternalAgentConfigImportProgressNotification = + serde_json::from_value(progress.params.expect("progress params"))?; + assert_eq!(progress.import_id, import_id); + assert_eq!(progress.item_type_results.len(), 1); + assert_eq!( + progress.item_type_results[0].item_type, + ExternalAgentConfigMigrationItemType::Config + ); + let notification = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), @@ -77,20 +112,229 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl let completed: ExternalAgentConfigImportCompletedNotification = serde_json::from_value(notification.params.expect("completed params"))?; assert_eq!(completed.import_id, import_id); + let state_db = + codex_state::StateRuntime::init(sqlite_home.path().to_path_buf(), "mock_provider".into()) + .await?; + let details_record = state_db + .external_agent_config_import_details_record(&import_id) + .await? + .expect("completed import details should be recorded by import id"); + let expected_successes = completed + .item_type_results + .iter() + .flat_map(|type_result| type_result.successes.iter()) + .collect::>(); + let expected_failures = completed + .item_type_results + .iter() + .flat_map(|type_result| type_result.failures.iter()) + .collect::>(); + assert_eq!( + serde_json::to_value(&details_record.successes)?, + serde_json::to_value(&expected_successes)? + ); + assert_eq!( + serde_json::to_value(&details_record.failures)?, + serde_json::to_value(&expected_failures)? + ); + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import/readHistories", + /*params*/ None, + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportHistoriesReadResponse = to_response(response)?; + let entry = response + .data + .iter() + .find(|entry| entry.import_id == import_id) + .expect("import history entry should be available"); + assert!(entry.completed_at_ms > 0); + assert_eq!( + serde_json::to_value(&entry.successes)?, + serde_json::to_value(&expected_successes)? + ); + assert_eq!( + serde_json::to_value(&entry.failures)?, + serde_json::to_value(&expected_failures)? + ); Ok(()) } #[tokio::test] -async fn external_agent_config_import_returns_error_for_failed_sync_import() -> Result<()> { +async fn external_agent_config_import_reports_failed_sync_import_in_completion() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{"env":{"FOO":"bar"}}"#, )?; std::fs::write(codex_home.path().join("config.toml"), "invalid = [")?; let home_dir = codex_home.path().display().to_string(); + let analytics_capture_file = codex_home.path().join("analytics-events.jsonl"); + let analytics_capture_file = analytics_capture_file.display().to_string(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_dir.as_str())), + ( + "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE", + Some(analytics_capture_file.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "source": "test_import", + "migrationItems": [ + { + "itemType": "CONFIG", + "description": "Import config", + "cwd": null + }, + { + "itemType": "COMMANDS", + "description": "Import commands", + "cwd": null + } + ] + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + let config_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Config) + .expect("config result"); + assert!(config_result.successes.is_empty()); + assert_eq!(config_result.failures.len(), 1); + let config_failure = &config_result.failures[0]; + assert_eq!( + config_failure.error_type.as_deref(), + Some("invalid_existing_config") + ); + assert_eq!(config_failure.failure_stage, "import_request_failed"); + assert!( + config_failure + .message + .contains("invalid existing config.toml"), + "unexpected failure: {config_failure:?}" + ); + let commands_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Commands) + .expect("commands result"); + assert!(commands_result.successes.is_empty()); + assert!(commands_result.failures.is_empty()); + + let events = timeout(DEFAULT_TIMEOUT, async { + loop { + let contents = match std::fs::read_to_string(&analytics_capture_file) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + } + Err(err) => return Err(err.into()), + }; + let mut captured_events = Vec::new(); + for line in contents.lines() { + let payload: serde_json::Value = serde_json::from_str(line)?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + captured_events.extend(events.iter().cloned()); + } + if captured_events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_complete" + && event["event_params"]["type"] == "COMMANDS" + }) { + return Ok::, anyhow::Error>(captured_events); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await??; + let event = events + .iter() + .find(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "CONFIG" + }) + .expect("config failure analytics event"); + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], import_id); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "CONFIG"); + assert_eq!(event_params["failure_stage"], "import_request_failed"); + assert_eq!(event_params["error_type"], "invalid_existing_config"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); + assert!(!events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "COMMANDS" + })); + + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_completed_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let missing_session_path = + external_agent_home(codex_home.path()).join("projects/repo/missing.jsonl"); + let project_root = codex_home.path().join("repo"); + let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) .await?; @@ -100,25 +344,70 @@ async fn external_agent_config_import_returns_error_for_failed_sync_import() -> .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ + "source": "test_import", "migrationItems": [{ - "itemType": "CONFIG", - "description": "Import config", - "cwd": null + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": missing_session_path, + "cwd": project_root, + "title": "missing session" + }] + } }] })), ) .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); - let error: JSONRPCError = timeout( + let notification = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), ) .await??; - assert_eq!(error.error.code, -32603); - assert!( - error.error.message.contains("invalid existing config.toml"), - "unexpected error: {error:?}" - ); + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + assert_eq!(completed.item_type_results.len(), 1); + assert_eq!(completed.item_type_results[0].successes.len(), 0); + assert_eq!(completed.item_type_results[0].failures.len(), 1); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_complete", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["success_count"], 0); + assert_eq!(event_params["failed_count"], 1); + assert!(event_params.get("raw_errors").is_none()); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_failure", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["failure_stage"], "session_missing"); + assert_eq!(event_params["error_type"], "session_missing"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); Ok(()) } @@ -150,7 +439,8 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","version":"0.1.0"}"#, )?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; let settings = serde_json::json!({ "enabledPlugins": { "sample@debug": true @@ -163,7 +453,7 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl } }); std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), serde_json::to_string_pretty(&settings)?, )?; @@ -242,11 +532,12 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl async fn external_agent_config_import_sends_completion_notification_after_pending_plugins_finish() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; // This test only needs a pending non-local plugin import. Use an invalid // source so the background completion path cannot make a real network clone. std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true @@ -311,7 +602,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -537,7 +828,7 @@ required = true std::fs::write(codex_home.path().join("config.toml"), config)?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -622,7 +913,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -712,7 +1003,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -806,28 +1097,18 @@ async fn external_agent_config_import_returns_before_background_session_import_f create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; - std::fs::write( - &session_path, - serde_json::json!({ - "type": "user", - "cwd": &project_root, - "timestamp": &recent_timestamp, - "message": { "content": "first request" }, - }) - .to_string(), - )?; - - let project_config_dir = project_root.join(".codex"); - std::fs::create_dir_all(&project_config_dir)?; - let project_config = project_config_dir.join("config.toml"); - let status = std::process::Command::new("mkfifo") - .arg(&project_config) - .status()?; - assert!(status.success()); + let session_contents = serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(); + std::fs::write(&session_path, &session_contents)?; let home_dir = codex_home.path().display().to_string(); let mut mcp = @@ -850,6 +1131,12 @@ async fn external_agent_config_import_returns_before_background_session_import_f assert_eq!(detected.items.len(), 1); let detected_items = detected.items; + std::fs::remove_file(&session_path)?; + let status = std::process::Command::new("mkfifo") + .arg(&session_path) + .status()?; + assert!(status.success()); + let request_id = mcp .send_raw_request( "externalAgentConfig/import", @@ -888,17 +1175,17 @@ async fn external_agent_config_import_returns_before_background_session_import_f let response: ExternalAgentConfigImportResponse = to_response(response)?; let duplicate_import_id = assert_import_response(response); - let writer = tokio::spawn(async move { - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .open(&project_config) - .await?; - file.write_all(b"\n").await - }); - timeout(DEFAULT_TIMEOUT, writer).await???; - let mut completed_import_ids = Vec::new(); for _ in 0..2 { + timeout(DEFAULT_TIMEOUT, async { + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .open(&session_path) + .await?; + file.write_all(session_contents.as_bytes()).await + }) + .await??; + let notification = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), @@ -971,7 +1258,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -1137,3 +1424,10 @@ stream_max_retries = 0 ), ) } + +fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!("chatgpt_base_url = \"{base_url}\"\n"), + ) +} 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 5c18aa8a4e7a..7a29bb35421b 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); diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 0cf8504d3bb5..6fb7aa3bc314 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -214,6 +214,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), + mcp_server_openai_form_elicitation: false, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/mcp__resource.rs b/codex-rs/app-server/tests/suite/v2/mcp__resource.rs index dc3b6d577037..98b0fa351296 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp__resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp__resource.rs @@ -436,6 +436,95 @@ async fn local_executor_does_not_expose_orchestrator_skills() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn disabled_orchestrator_skills_do_not_expose_skills_namespace() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (apps_server_url, apps_server_calls, apps_server_handle) = + start_resource_apps_mcp_server().await?; + let responses_server_uri = responses_server.uri(); + let (_codex_home, mut mcp) = start_resource_test_app_server_with_extra_config( + &apps_server_url, + &responses_server_uri, + r#" +[orchestrator.skills] +enabled = false +"#, + ) + .await?; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + environments: Some(Vec::new()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let response_mock = responses::mount_sse_once( + &responses_server, + responses::sse(vec![ + responses::ev_response_created("resp-disabled-orchestrator-skills"), + responses::ev_assistant_message("msg-disabled-orchestrator-skills", "Done"), + responses::ev_completed("resp-disabled-orchestrator-skills"), + ]), + ) + .await; + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: format!("Use ${SKILL_NAME}"), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + assert!(request.tool_by_name("skills", "list").is_none()); + assert!(request.tool_by_name("skills", "read").is_none()); + assert!( + request + .message_input_texts("developer") + .iter() + .all(|text| !text.contains(SKILL_NAME)) + ); + assert!( + request + .message_input_texts("user") + .iter() + .all(|text| !text.contains(SKILL_MARKER)) + ); + assert_eq!( + ResourceAppsMcpCallCounts { + list_resources: 0, + main_prompt_reads: 0, + reference_reads: 0, + }, + apps_server_calls.snapshot() + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_resource_read_returns_resource_contents_without_thread() -> Result<()> { let (apps_server_url, _apps_server_calls, apps_server_handle) = @@ -555,6 +644,15 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { async fn start_resource_test_app_server( apps_server_url: &str, responses_server_uri: &str, +) -> Result<(TempDir, TestAppServer)> { + start_resource_test_app_server_with_extra_config(apps_server_url, responses_server_uri, "") + .await +} + +async fn start_resource_test_app_server_with_extra_config( + apps_server_url: &str, + responses_server_uri: &str, + extra_config: &str, ) -> Result<(TempDir, TestAppServer)> { let codex_home = TempDir::new()?; std::fs::write( @@ -574,6 +672,7 @@ apps = true [skills] include_instructions = true +{extra_config} [model_providers.mock_provider] name = "Mock provider for test" diff --git a/codex-rs/app-server/tests/suite/v2/mcp__server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp__server_elicitation.rs index b4bd1d377a92..113f58f33644 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp__server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp__server_elicitation.rs @@ -14,6 +14,9 @@ use axum::http::StatusCode; use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpElicitationSchema; @@ -24,6 +27,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; @@ -34,6 +38,7 @@ use codex_app_server_protocol::UserInput as V2UserInput; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::assert_regex_match; use core_test_support::responses; +use core_test_support::responses::ResponseMock; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::BooleanSchema; @@ -41,14 +46,18 @@ use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::Content; use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::CustomRequest; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; use rmcp::model::Meta; use rmcp::model::PrimitiveSchema; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; +use rmcp::model::ServerRequest as McpServerRequest; use rmcp::model::Tool; use rmcp::model::ToolAnnotations; use rmcp::service::RequestContext; @@ -63,6 +72,15 @@ use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use super::connection_handling_websocket::WsClient; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::read_jsonrpc_message; +use super::connection_handling_websocket::read_notification_for_method; +use super::connection_handling_websocket::read_response_for_id; +use super::connection_handling_websocket::send_jsonrpc; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; @@ -71,40 +89,88 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action"; const TOOL_NAME: &str = "calendar_confirm_action"; const TOOL_CALL_ID: &str = "call-calendar-confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const OPENAI_FORM_MESSAGE: &str = "Select a template"; +const IMAGE_DATA_URL: &str = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="; + +#[derive(Clone, Copy)] +enum ElicitationScenario { + StandardForm, + OpenAiForm, +} #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn mcp_server_elicitation_round_trip() -> Result<()> { - let responses_server = responses::start_mock_server().await; - let tool_call_arguments = serde_json::to_string(&json!({}))?; - let response_mock = responses::mount_sse_sequence( - &responses_server, - vec![ - responses::sse(vec![ - responses::ev_response_created("resp-0"), - responses::ev_assistant_message("msg-0", "Warmup"), - responses::ev_completed("resp-0"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_function_call_with_namespace( - TOOL_CALL_ID, - TOOL_NAMESPACE, - CALLABLE_TOOL_NAME, - &tool_call_arguments, - ), - responses::ev_completed("resp-1"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-2"), - ]), - ], - ) - .await; +async fn mcp_server_form_elicitation_round_trip() -> Result<()> { + let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::StandardForm).await?; + let (request_id, params) = fixture.read_elicitation().await?; + let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( + ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(anyhow::Error::msg)?, + )?)?; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: fixture.thread_id.clone(), + turn_id: Some(fixture.turn_id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }, + } + ); + + fixture + .accept(request_id.clone(), json!({ "confirmed": true })) + .await?; + fixture.finish(request_id, "accepted").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> { + let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiForm).await?; + let (request_id, params) = fixture.read_elicitation().await?; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: fixture.thread_id.clone(), + turn_id: Some(fixture.turn_id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::OpenAiForm { + meta: None, + message: OPENAI_FORM_MESSAGE.to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }), + }, + } + ); - let (apps_server_url, apps_server_handle) = start_apps_server().await?; + fixture + .accept(request_id.clone(), json!({ "template": "monthly-review" })) + .await?; + fixture.finish(request_id, "accepted monthly-review").await +} +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn openai_form_capability_follows_the_turn_starting_connection() -> Result<()> { + let (responses_server, response_mock, apps_server_url, apps_server_handle) = + start_elicitation_services(ElicitationScenario::OpenAiForm).await?; let codex_home = TempDir::new()?; write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; write_chatgpt_auth( @@ -116,187 +182,430 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let mut supported_client = connect_websocket(bind_addr).await?; + initialize_websocket_client( + &mut supported_client, + /*id*/ 1, + "supported-client", + /*supports_openai_form_elicitation*/ true, + ) + .await?; - let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { + send_request( + &mut supported_client, + "thread/start", + /*id*/ 2, + Some(serde_json::to_value(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() - }) - .await?; - let thread_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + })?), ) - .await??; - let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; - - let warmup_turn_start_id = mcp - .send_turn_start_request(TurnStartParams { + .await?; + let ThreadStartResponse { thread, .. } = + to_response(read_response_for_id(&mut supported_client, /*id*/ 2).await?)?; + + send_request( + &mut supported_client, + "turn/start", + /*id*/ 3, + Some(serde_json::to_value(TurnStartParams { thread_id: thread.id.clone(), - client_user_message_id: None, input: vec![V2UserInput::Text { text: "Warm up connectors.".to_string(), text_elements: Vec::new(), }], model: Some("mock-model".to_string()), ..Default::default() - }) - .await?; - let warmup_turn_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), + })?), ) - .await??; - let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; - - let warmup_completed = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - let warmup_completed: TurnCompletedNotification = serde_json::from_value( - warmup_completed + .await?; + let _: TurnStartResponse = + to_response(read_response_for_id(&mut supported_client, /*id*/ 3).await?)?; + let _: TurnCompletedNotification = serde_json::from_value( + read_notification_for_method(&mut supported_client, "turn/completed") + .await? .params - .clone() - .expect("warmup turn/completed params"), + .expect("turn/completed params"), )?; - assert_eq!(warmup_completed.thread_id, thread.id); - assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); - let turn_start_id = mcp - .send_turn_start_request(TurnStartParams { + let mut unsupported_client = connect_websocket(bind_addr).await?; + initialize_websocket_client( + &mut unsupported_client, + /*id*/ 4, + "unsupported-client", + /*supports_openai_form_elicitation*/ false, + ) + .await?; + send_request( + &mut unsupported_client, + "thread/resume", + /*id*/ 5, + Some(serde_json::to_value(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + })?), + ) + .await?; + let _ = read_response_for_id(&mut unsupported_client, /*id*/ 5).await?; + + send_request( + &mut supported_client, + "turn/start", + /*id*/ 6, + Some(serde_json::to_value(TurnStartParams { thread_id: thread.id.clone(), - client_user_message_id: None, input: vec![V2UserInput::Text { text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), text_elements: Vec::new(), }], model: Some("mock-model".to_string()), ..Default::default() - }) - .await?; - let turn_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + })?), ) - .await??; - let TurnStartResponse { turn } = to_response(turn_start_resp)?; + .await?; + let TurnStartResponse { turn } = + to_response(read_response_for_id(&mut supported_client, /*id*/ 6).await?)?; - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { - panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + let (request_id, params) = loop { + let JSONRPCMessage::Request(request) = read_jsonrpc_message(&mut supported_client).await? + else { + continue; + }; + let request: ServerRequest = serde_json::from_value(serde_json::to_value(request)?)?; + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + continue; + }; + break (request_id, params); }; - let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( - ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .map_err(anyhow::Error::msg)?, - )?)?; - assert_eq!( - params, - McpServerElicitationRequestParams { - thread_id: thread.id.clone(), - turn_id: Some(turn.id.clone()), - server_name: "codex_apps".to_string(), - request: McpServerElicitationRequest::Form { - meta: None, - message: ELICITATION_MESSAGE.to_string(), - requested_schema, - }, + params.request, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: OPENAI_FORM_MESSAGE.to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }), } ); + send_jsonrpc( + &mut supported_client, + JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result: serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ "template": "monthly-review" })), + meta: None, + })?, + }), + ) + .await?; - let resolved_request_id = request_id.clone(); - mcp.send_response( - request_id, - serde_json::to_value(McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - meta: None, - })?, + let completed: TurnCompletedNotification = serde_json::from_value( + read_notification_for_method(&mut supported_client, "turn/completed") + .await? + .params + .expect("turn/completed params"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(response_mock.requests().len(), 3); + + process.kill().await?; + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +async fn initialize_websocket_client( + client: &mut WsClient, + id: i64, + name: &str, + supports_openai_form_elicitation: bool, +) -> Result<()> { + send_request( + client, + "initialize", + id, + Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: name.to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + mcp_server_openai_form_elicitation: supports_openai_form_elicitation, + ..Default::default() + }), + })?), ) .await?; + let _ = read_response_for_id(client, id).await?; + Ok(()) +} - let mut saw_resolved = false; - loop { - let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; - let JSONRPCMessage::Notification(notification) = message else { - continue; +async fn start_elicitation_services( + scenario: ElicitationScenario, +) -> Result<(wiremock::MockServer, ResponseMock, String, JoinHandle<()>)> { + let responses_server = responses::start_mock_server().await; + let tool_call_arguments = serde_json::to_string(&json!({}))?; + let response_mock = responses::mount_sse_sequence( + &responses_server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-0"), + responses::ev_assistant_message("msg-0", "Warmup"), + responses::ev_completed("resp-0"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + TOOL_CALL_ID, + TOOL_NAMESPACE, + CALLABLE_TOOL_NAME, + &tool_call_arguments, + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let (apps_server_url, apps_server_handle) = start_apps_server(scenario).await?; + Ok(( + responses_server, + response_mock, + apps_server_url, + apps_server_handle, + )) +} + +struct ElicitationRoundTripFixture { + mcp: TestAppServer, + response_mock: ResponseMock, + _responses_server: wiremock::MockServer, + thread_id: String, + turn_id: String, + apps_server_handle: JoinHandle<()>, +} + +impl ElicitationRoundTripFixture { + async fn start(scenario: ElicitationScenario) -> Result { + let (responses_server, response_mock, apps_server_url, apps_server_handle) = + start_elicitation_services(scenario).await?; + let codex_home = TempDir::new()?; + write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + mcp_server_openai_form_elicitation: true, + ..Default::default() + }), + ), + ) + .await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let warmup_turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Warm up connectors.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let warmup_turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; + let warmup_completed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let warmup_completed: TurnCompletedNotification = serde_json::from_value( + warmup_completed + .params + .clone() + .expect("warmup turn/completed params"), + )?; + assert_eq!(warmup_completed.thread_id, thread.id); + assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_start_resp)?; + + Ok(Self { + mcp, + response_mock, + _responses_server: responses_server, + thread_id: thread.id, + turn_id: turn.id, + apps_server_handle, + }) + } + + async fn read_elicitation(&mut self) -> Result<(RequestId, McpServerElicitationRequestParams)> { + let request = timeout( + DEFAULT_READ_TIMEOUT, + self.mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + panic!("expected McpServerElicitationRequest request, got: {request:?}"); }; + Ok((request_id, params)) + } - match notification.method.as_str() { - "serverRequest/resolved" => { - let resolved: ServerRequestResolvedNotification = serde_json::from_value( - notification - .params - .clone() - .expect("serverRequest/resolved params"), - )?; - assert_eq!( - resolved, - ServerRequestResolvedNotification { - thread_id: thread.id.clone(), - request_id: resolved_request_id.clone(), - } - ); - saw_resolved = true; - } - "turn/completed" => { - let completed: TurnCompletedNotification = serde_json::from_value( - notification.params.clone().expect("turn/completed params"), - )?; - assert!(saw_resolved, "serverRequest/resolved should arrive first"); - assert_eq!(completed.thread_id, thread.id); - assert_eq!(completed.turn.id, turn.id); - assert_eq!(completed.turn.status, TurnStatus::Completed); - break; + async fn accept(&mut self, request_id: RequestId, content: Value) -> Result<()> { + self.mcp + .send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(content), + meta: None, + })?, + ) + .await + } + + async fn finish(mut self, request_id: RequestId, expected_text: &str) -> Result<()> { + let mut resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, self.mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let notification: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(notification.thread_id, self.thread_id); + assert_eq!(notification.request_id, request_id); + resolved = true; + } + "turn/completed" => { + let notification: TurnCompletedNotification = serde_json::from_value( + notification.params.clone().expect("turn/completed params"), + )?; + assert!( + resolved, + "server request should resolve before turn completion" + ); + assert_eq!(notification.thread_id, self.thread_id); + assert_eq!(notification.turn.id, self.turn_id); + assert_eq!(notification.turn.status, TurnStatus::Completed); + break; + } + _ => {} } - _ => {} } - } - let requests = response_mock.requests(); - assert_eq!(requests.len(), 3); - let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); - assert_eq!( - function_call_output.get("type"), - Some(&Value::String("function_call_output".to_string())) - ); - assert_eq!( - function_call_output.get("call_id"), - Some(&Value::String(TOOL_CALL_ID.to_string())) - ); - let output = function_call_output - .get("output") - .and_then(Value::as_str) - .expect("function_call_output output should be a JSON string"); - let payload = assert_regex_match( - r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#, - output, - ) - .get(1) - .expect("wall-time wrapped output should include payload") - .as_str(); - assert_eq!( - serde_json::from_str::(payload)?, - json!([{ - "type": "text", - "text": "accepted" - }]) - ); + let requests = self.response_mock.requests(); + assert_eq!(requests.len(), 3); + let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); + assert_eq!( + function_call_output.get("type"), + Some(&Value::String("function_call_output".to_string())) + ); + assert_eq!( + function_call_output.get("call_id"), + Some(&Value::String(TOOL_CALL_ID.to_string())) + ); + let output = function_call_output + .get("output") + .and_then(Value::as_str) + .expect("function_call_output output should be a JSON string"); + let payload = assert_regex_match( + r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#, + output, + ) + .get(1) + .expect("wall-time wrapped output should include payload") + .as_str(); + assert_eq!( + serde_json::from_str::(payload)?, + json!([{ "type": "text", "text": expected_text }]) + ); - apps_server_handle.abort(); - let _ = apps_server_handle.await; - Ok(()) + self.apps_server_handle.abort(); + let _ = self.apps_server_handle.await; + Ok(()) + } } #[derive(Clone)] @@ -305,10 +614,33 @@ struct AppsServerState { expected_account_id: String, } -#[derive(Clone, Default)] -struct ElicitationAppsMcpServer; +#[derive(Clone)] +struct ElicitationAppsMcpServer { + scenario: ElicitationScenario, +} impl ServerHandler for ElicitationAppsMcpServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + if matches!(self.scenario, ElicitationScenario::OpenAiForm) { + assert_eq!( + request + .capabilities + .extensions + .as_ref() + .and_then(|extensions| extensions.get("openai/form")) + .cloned() + .map(Value::Object), + Some(json!({})) + ); + } + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18) @@ -351,40 +683,91 @@ impl ServerHandler for ElicitationAppsMcpServer { _request: CallToolRequestParams, context: RequestContext, ) -> Result { - let requested_schema = ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - - let result = context - .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: ELICITATION_MESSAGE.to_string(), - requested_schema, - }) - .await - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - - let output = match result.action { - ElicitationAction::Accept => { + match self.scenario { + ElicitationScenario::StandardForm => { + let requested_schema = ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; assert_eq!( result.content, Some(json!({ "confirmed": true, })) ); - "accepted" + let output = match result.action { + ElicitationAction::Accept => "accepted", + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + Ok(CallToolResult::success(vec![Content::text(output)])) } - ElicitationAction::Decline => "declined", - ElicitationAction::Cancel => "cancelled", - }; - - Ok(CallToolResult::success(vec![Content::text(output)])) + ElicitationScenario::OpenAiForm => { + let result = context + .peer + .send_request(McpServerRequest::CustomRequest(CustomRequest::new( + "openai/form", + Some(json!({ + "message": OPENAI_FORM_MESSAGE, + "requestedSchema": { + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }, + })), + ))) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = match result { + rmcp::model::ClientResult::CustomResult(result) => result.0, + rmcp::model::ClientResult::CreateElicitationResult(result) => { + serde_json::to_value(result) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))? + } + result => { + return Err(rmcp::ErrorData::internal_error( + format!("unexpected OpenAI form response: {result:?}"), + None, + )); + } + }; + assert_eq!( + result, + json!({ + "action": "accept", + "content": { + "template": "monthly-review", + }, + }) + ); + Ok(CallToolResult::success(vec![Content::text( + "accepted monthly-review", + )])) + } + } } } -async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { +async fn start_apps_server(scenario: ElicitationScenario) -> Result<(String, JoinHandle<()>)> { let state = Arc::new(AppsServerState { expected_bearer: "Bearer chatgpt-token".to_string(), expected_account_id: "account-123".to_string(), @@ -394,7 +777,7 @@ async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { let addr = listener.local_addr()?; let mcp_service = StreamableHttpService::new( - move || Ok(ElicitationAppsMcpServer), + move || Ok(ElicitationAppsMcpServer { scenario }), Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig::default(), ); diff --git a/codex-rs/app-server/tests/suite/v2/mcp__tool.rs b/codex-rs/app-server/tests/suite/v2/mcp__tool.rs index 10c8a6ef0422..07c2e9fed749 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp__tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp__tool.rs @@ -513,6 +513,7 @@ url = "{mcp_server_url}/mcp" tool, status, arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(result), diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 2cf877f1035b..24fb5b4b64d2 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -13,7 +13,9 @@ mod config_rpc; mod connection_handling_websocket; #[cfg(unix)] mod connection_handling_websocket_unix; +mod current_time; mod dynamic_tools; +mod environment_add; #[cfg(not(target_os = "windows"))] mod executor_mcp; mod executor_skills; @@ -58,6 +60,7 @@ mod process_exec; mod rate_limit_reset_credits; mod rate_limits; mod realtime_conversation; +mod recommended_plugins; mod remote_control; #[cfg(debug_assertions)] mod remote_thread_store; 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 63e92af57855..5d02cafa0a5c 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/plugins__install.rs b/codex-rs/app-server/tests/suite/v2/plugins__install.rs index 6965e759e0d3..aebc30dfeadc 100644 --- a/codex-rs/app-server/tests/suite/v2/plugins__install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugins__install.rs @@ -511,6 +511,46 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_empty_remote_installed_plugins(&server).await; + mount_backend_analytics_events(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("failed with status 404")); + + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["plugin_name"], "unknown"); + assert_eq!( + event_params["marketplace_name"], + "caller-marketplace-is-ignored" + ); + assert_eq!( + event_params["error_type"], + "remote_catalog_unexpected_status" + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> { let codex_home = TempDir::new()?; @@ -571,6 +611,42 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_remote_plugin_not_available() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail_with_install_policy( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + /*install_policy*/ "NOT_AVAILABLE", + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("not available for install")); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -820,6 +896,66 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./missing-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(err.error.code, -32600); + + let payload = wait_for_plugin_analytics_payload(&analytics_server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], "sample-plugin@debug"); + assert_eq!(event_params["plugin_name"], "sample-plugin"); + assert_eq!(event_params["marketplace_name"], "debug"); + assert_eq!(event_params["has_skills"], json!(null)); + assert_eq!(event_params["mcp_server_count"], json!(null)); + assert_eq!(event_params["connector_ids"], json!(null)); + assert_eq!(event_params["product_client_id"], DEFAULT_CLIENT_NAME); + assert_eq!(event_params["error_type"], "store_invalid"); + Ok(()) +} + #[tokio::test] async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { let codex_home = TempDir::new()?; @@ -874,19 +1010,17 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { } #[tokio::test] -async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> { +async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_large() -> Result<()> +{ let codex_home = TempDir::new()?; let server = MockServer::start().await; - let bundle_url = mount_remote_plugin_bundle( - &server, - /*status_code*/ 503, - b"bundle temporarily unavailable".to_vec(), - ) - .await; + let bundle_url = + mount_remote_plugin_bundle(&server, /*status_code*/ 503, vec![b'x'; 8 * 1024 + 1]).await; configure_remote_plugin_test(codex_home.path(), &server)?; mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + mount_backend_analytics_events(&server).await; let mut mcp = TestAppServer::new_with_env( codex_home.path(), @@ -904,6 +1038,20 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> assert_eq!(err.error.code, -32603); assert!(err.error.message.contains("failed with status 503")); + assert!( + err.error + .message + .contains("[response body truncated after 8192 bytes]") + ); + assert_eq!( + err.error + .message + .bytes() + .filter(|byte| *byte == b'x') + .count(), + 8192 + ); + assert!(!err.error.message.contains("exceeded maximum size")); wait_for_remote_plugin_request_count( &server, "GET", @@ -918,6 +1066,15 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> /*expected_count*/ 0, ) .await?; + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["marketplace_name"], "openai-curated-remote"); + assert_eq!(event_params["error_type"], "remote_bundle_download_status"); assert!( !codex_home .path() @@ -1099,7 +1256,7 @@ async fn plugin_install_skips_mcp_oauth_for_chatgpt_dual_surface_plugin() -> Res } #[tokio::test] -async fn plugin_install_starts_mcp_oauth_when_only_plugin_apps_are_disallowed() -> Result<()> { +async fn plugin_install_starts_mcp_oauth_with_formerly_disallowed_plugin_app() -> Result<()> { let (apps_server_url, apps_server_handle, _apps_server_control) = start_apps_server(Vec::new(), Vec::new()).await?; let oauth_server = MockServer::start().await; @@ -1150,8 +1307,22 @@ async fn plugin_install_starts_mcp_oauth_when_only_plugin_apps_are_disallowed() .await??; let response: PluginInstallResponse = to_response(response)?; - assert_eq!(response.auth_policy, PluginAuthPolicy::OnInstall); - assert_eq!(response.apps_needing_auth, Vec::::new()); + assert_eq!( + response, + PluginInstallResponse { + auth_policy: PluginAuthPolicy::OnInstall, + apps_needing_auth: vec![AppSummary { + id: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + name: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + description: None, + install_url: Some( + "https://chatgpt.com/apps/asdk-app-6938a94a61d881918ef32cb999ff937c/asdk_app_6938a94a61d881918ef32cb999ff937c" + .to_string(), + ), + category: None, + }], + } + ); assert!(oauth_discovery_request_count(&oauth_server).await > 0); apps_server_handle.abort(); @@ -1159,6 +1330,97 @@ async fn plugin_install_starts_mcp_oauth_when_only_plugin_apps_are_disallowed() Ok(()) } +#[tokio::test] +async fn plugin_install_starts_mcp_oauth_through_protected_resource_metadata() -> Result<()> { + let resource_server = MockServer::start().await; + let authorization_server = MockServer::start().await; + let resource_metadata_url = format!("{}/oauth-resource", resource_server.uri()); + let challenge = format!("Bearer resource_metadata=\"{resource_metadata_url}\""); + Mock::given(method("GET")) + .and(path("/mcp")) + .respond_with( + ResponseTemplate::new(401).insert_header("WWW-Authenticate", challenge.as_str()), + ) + .mount(&resource_server) + .await; + Mock::given(method("GET")) + .and(path("/oauth-resource")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "resource": resource_server.uri(), + "authorization_servers": [authorization_server.uri()], + }))) + .mount(&resource_server) + .await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", authorization_server.uri()), + "token_endpoint": format!("{}/oauth/token", authorization_server.uri()), + "registration_endpoint": format!("{}/oauth/register", authorization_server.uri()), + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + }))) + .mount(&authorization_server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/register")) + .respond_with(ResponseTemplate::new(400)) + .mount(&authorization_server) + .await; + + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nplugins = true\n", + )?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + write_plugin_mcp_config(repo_root.path(), "sample-plugin", &resource_server.uri())?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: PluginInstallResponse = to_response(response)?; + wait_for_remote_plugin_request_count( + &authorization_server, + "POST", + "/oauth/register", + /*expected_count*/ 1, + ) + .await?; + + let resource_metadata_requested = resource_server + .received_requests() + .await + .unwrap_or_default() + .iter() + .any(|request| request.url.path() == "/oauth-resource"); + assert!(resource_metadata_requested); + Ok(()) +} + #[tokio::test] async fn plugin_install_starts_mcp_oauth_for_api_key_dual_surface_plugin() -> Result<()> { let oauth_server = MockServer::start().await; @@ -1315,7 +1577,7 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re } #[tokio::test] -async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { +async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Result<()> { let connectors = vec![AppInfo { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -1392,6 +1654,16 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { description: Some("Alpha connector".to_string()), install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), category: None, + }, + AppSummary { + id: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + name: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + description: None, + install_url: Some( + "https://chatgpt.com/apps/asdk-app-6938a94a61d881918ef32cb999ff937c/asdk_app_6938a94a61d881918ef32cb999ff937c" + .to_string(), + ), + category: None, }], } ); @@ -1867,6 +2139,45 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( bundle_download_url: Option<&str>, status: PluginAvailability, app_manifest: Option, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + bundle_download_url, + status, + "AVAILABLE", + app_manifest, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_install_policy( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + install_policy: &str, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + /*bundle_download_url*/ None, + PluginAvailability::Available, + install_policy, + /*app_manifest*/ None, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_options( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, + install_policy: &str, + app_manifest: Option, ) { let status = match status { PluginAvailability::Available => "ENABLED", @@ -1883,7 +2194,7 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( "id": "{remote_plugin_id}", "name": "linear", "scope": "GLOBAL", - "installation_policy": "AVAILABLE", + "installation_policy": "{install_policy}", "authentication_policy": "ON_USE", "status": "{status}", "release": {{ 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 d45dcdc3a5d6..86ea09504c65 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/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index f88ed799a077..199e3a72d52b 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -78,6 +78,7 @@ use wiremock::matchers::path; use wiremock::matchers::path_regex; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DELEGATED_SHELL_TURN_TIMEOUT: Duration = Duration::from_secs(30); const DELEGATED_SHELL_TOOL_TIMEOUT_MS: u64 = 30_000; const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex."; const V2_STEERING_ACKNOWLEDGEMENT: &str = @@ -86,6 +87,8 @@ const V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT: &str = "Background agent finished. Use the preceding [BACKEND] messages as the result."; const RESPONSE_ITEM_PREFIX: &str = "Use the following context to inform future responses, but do not speak it to the user."; +const RESPONSE_HANDOFF_PREFIX: &str = + "Silent Codex context. Do not speak, acknowledge, or summarize this item."; #[derive(Debug, Clone, Copy)] enum StartupContextConfig<'a> { @@ -313,8 +316,9 @@ impl RealtimeE2eHarness { } async fn start_webrtc_realtime(&mut self, offer_sdp: &str) -> Result { - self.start_webrtc_realtime_with_codex_responses_as_items( - offer_sdp, /*codex_responses_as_items*/ None, + self.start_webrtc_realtime_with_codex_response_routing( + offer_sdp, /*client_managed_handoffs*/ None, + /*codex_responses_as_items*/ None, /*codex_response_handoff_prefix*/ None, ) .await } @@ -323,28 +327,33 @@ impl RealtimeE2eHarness { &mut self, offer_sdp: &str, ) -> Result { - self.start_webrtc_realtime_with_codex_responses_as_items( + self.start_webrtc_realtime_with_codex_response_routing( offer_sdp, + /*client_managed_handoffs*/ None, /*codex_responses_as_items*/ Some(true), + /*codex_response_handoff_prefix*/ None, ) .await } - async fn start_webrtc_realtime_with_codex_responses_as_items( + async fn start_webrtc_realtime_with_codex_response_routing( &mut self, offer_sdp: &str, + client_managed_handoffs: Option, codex_responses_as_items: Option, + codex_response_handoff_prefix: Option<&str>, ) -> Result { // Starts realtime through the public JSON-RPC method, then waits for the same client-visible // notifications a desktop app needs: started first, SDP answer second. let start_request_id = self .mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs, thread_id: self.thread_id.clone(), codex_response_item_prefix: codex_responses_as_items .unwrap_or(false) .then(|| RESPONSE_ITEM_PREFIX.to_string()), + codex_response_handoff_prefix: codex_response_handoff_prefix.map(str::to_string), codex_responses_as_items, model: None, output_modality: RealtimeOutputModality::Audio, @@ -376,6 +385,58 @@ impl RealtimeE2eHarness { Ok(StartedWebrtcRealtime { started, sdp }) } + async fn start_websocket_realtime(&mut self) -> Result { + self.start_websocket_realtime_with_codex_responses_as_items( + /*codex_responses_as_items*/ None, + ) + .await + } + + async fn start_websocket_realtime_with_codex_response_items( + &mut self, + ) -> Result { + self.start_websocket_realtime_with_codex_responses_as_items( + /*codex_responses_as_items*/ Some(true), + ) + .await + } + + async fn start_websocket_realtime_with_codex_responses_as_items( + &mut self, + codex_responses_as_items: Option, + ) -> Result { + let start_request_id = self + .mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: self.thread_id.clone(), + client_managed_handoffs: None, + codex_response_item_prefix: codex_responses_as_items + .unwrap_or(false) + .then(|| RESPONSE_ITEM_PREFIX.to_string()), + codex_response_handoff_prefix: None, + codex_responses_as_items, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: None, + version: None, + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + self.mcp + .read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + self.read_notification::("thread/realtime/started") + .await + } + async fn read_notification(&mut self, method: &str) -> Result { read_notification(&mut self.mcp, method).await } @@ -529,6 +590,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "session": { "id": "sess_backend", "instructions": "backend prompt" } })], vec![], + vec![], vec![ json!({ "type": "response.output_audio.delta", @@ -581,7 +643,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "message": "upstream boom" }), ], - vec![], ]]) .await; @@ -610,9 +671,10 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, @@ -698,6 +760,20 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .await??; let _: ThreadRealtimeAppendTextResponse = to_response(text_append_response)?; + let assistant_append_request_id = mcp + .send_thread_realtime_append_text_request(ThreadRealtimeAppendTextParams { + thread_id: started.thread_id.clone(), + text: "welcome back".to_string(), + role: ConversationTextRole::Assistant, + }) + .await?; + let assistant_append_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(assistant_append_request_id)), + ) + .await??; + let _: ThreadRealtimeAppendTextResponse = to_response(assistant_append_response)?; + let output_audio = read_notification::( &mut mcp, "thread/realtime/outputAudio/delta", @@ -779,7 +855,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let connections = realtime_server.connections(); assert_eq!(connections.len(), 1); let connection = &connections[0]; - assert_eq!(connection.len(), 3); + assert_eq!(connection.len(), 4); assert_eq!( connection[0].body_json()["type"].as_str(), Some("session.update") @@ -788,13 +864,14 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { connection[0].body_json()["session"]["instructions"].as_str(), Some(startup_context_instructions.as_str()), ); - let text_request = connection + let text_requests = connection .iter() .map(WebSocketRequest::body_json) - .find(|request| request["type"] == "conversation.item.create") - .context("expected conversation item request")?; + .filter(|request| request["type"] == "conversation.item.create") + .collect::>(); + assert_eq!(text_requests.len(), 2); assert_eq!( - text_request, + text_requests[0], json!({ "type": "conversation.item.create", "item": { @@ -807,6 +884,20 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { }, }) ); + assert_eq!( + text_requests[1], + json!({ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "welcome back", + }], + }, + }) + ); let mut request_types = [ connection[1].body_json()["type"] .as_str() @@ -816,11 +907,16 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .as_str() .context("expected websocket request type")? .to_string(), + connection[3].body_json()["type"] + .as_str() + .context("expected websocket request type")? + .to_string(), ]; request_types.sort(); assert_eq!( request_types, [ + "conversation.item.create".to_string(), "conversation.item.create".to_string(), "input_audio_buffer.append".to_string(), ] @@ -866,9 +962,10 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -963,9 +1060,10 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Text, @@ -1143,9 +1241,10 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -1246,9 +1345,10 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let thread_id = thread_start.thread.id; let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -1273,7 +1373,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { read_notification::(&mut mcp, "thread/realtime/started") .await?; assert_eq!(started.thread_id, thread_id); - assert_eq!(started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V1); let sdp_notification = read_notification::(&mut mcp, "thread/realtime/sdp").await?; @@ -1300,7 +1400,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { ); assert_eq!( realtime_server.single_handshake().uri(), - "/v1/realtime?call_id=rtc_app_test" + "/v1/realtime?intent=quicksilver&call_id=rtc_app_test" ); let stop_request_id = mcp @@ -1329,7 +1429,10 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let request = call_capture.single_request(); assert_eq!(request.url.path(), "/v1/realtime/calls"); - assert_eq!(request.url.query(), None); + assert_eq!( + request.url.query(), + Some("intent=quicksilver&architecture=avas") + ); assert_eq!( request .headers @@ -1338,8 +1441,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { Some("multipart/form-data; boundary=codex-realtime-call-boundary") ); let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; - let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"transcription":{"model":"gpt-4o-mini-transcribe"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true,"silence_duration_ms":500}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}},{"type":"function","name":"remain_silent","description":"Call this when the best response is to say nothing. Use it instead of speaking after hidden system/control messages, after background agent updates in silent modes, or whenever acknowledging aloud would be distracting. This tool has no user-visible effect.","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}"#; - let session = normalized_json_string(session)?; + let session = normalized_json_string(v1_session_create_json())?; assert_eq!( body, format!( @@ -1480,6 +1582,135 @@ async fn webrtc_v1_default_automatic_output_uses_handoff_append() -> Result<()> Ok(()) } +#[tokio::test] +async fn webrtc_v1_client_managed_handoffs_disable_automatic_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "client-managed output", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![session_updated("sess_v1_client_managed_handoffs")], + vec![], + ])]), + ) + .await?; + + let started = harness + .start_webrtc_realtime_with_codex_response_routing( + "v=offer\r\n", + /*client_managed_handoffs*/ Some(true), + /*codex_responses_as_items*/ None, + /*codex_response_handoff_prefix*/ None, + ) + .await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V1); + assert_v1_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + + let turn_request_id = harness + .mcp + .send_turn_start_request(TurnStartParams { + thread_id: harness.thread_id.clone(), + input: vec![V2UserInput::Text { + text: "leave realtime delivery to the client".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + harness + .mcp + .read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + let _ = harness + .read_notification::("turn/completed") + .await?; + + let automatic_handoff = timeout( + Duration::from_millis(200), + harness + .realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 1), + ) + .await; + assert!( + automatic_handoff.is_err(), + "automatic Codex output should not reach realtime in client-managed handoff mode" + ); + + harness + .append_speech(harness.thread_id.clone(), "client-selected speech") + .await?; + assert_eq!( + harness.sideband_outbound_request(/*request_index*/ 1).await, + json!({ + "type": "conversation.handoff.append", + "handoff_id": "codex", + "output_text": "client-selected speech", + }) + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v1_final_automatic_handoff_omits_silent_prefix() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "background progress", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v1_prefixed_handoff"), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_prefixed", + "item_id": "item_prefixed", + "input_transcript": "run the background task" + }), + ], + vec![], + vec![], + ])]), + ) + .await?; + + let started = harness + .start_webrtc_realtime_with_codex_response_routing( + "v=offer\r\n", + /*client_managed_handoffs*/ None, + /*codex_responses_as_items*/ None, + Some(RESPONSE_HANDOFF_PREFIX), + ) + .await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V1); + let _ = harness + .read_notification::("turn/completed") + .await?; + + assert_eq!( + harness.sideband_outbound_request(/*request_index*/ 1).await, + json!({ + "type": "conversation.handoff.append", + "handoff_id": "handoff_prefixed", + "output_text": "background progress", + }) + ); + + harness.shutdown().await; + Ok(()) +} + #[tokio::test] async fn webrtc_v1_handoff_request_delegates_context_and_manual_append_speaks() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1603,9 +1834,9 @@ async fn realtime_automatic_standalone_output_is_item_and_append_speaks() -> Res .await?; let started = harness - .start_webrtc_realtime_with_codex_response_items("v=offer\r\n") + .start_websocket_realtime_with_codex_response_items() .await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_eq!( harness.sideband_outbound_request(/*request_index*/ 0).await["type"].as_str(), Some("session.update") @@ -1686,9 +1917,9 @@ async fn realtime_automatic_handoff_output_is_item_and_append_speaks() -> Result .await?; let started = harness - .start_webrtc_realtime_with_codex_response_items("v=offer\r\n") + .start_websocket_realtime_with_codex_response_items() .await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_eq!( harness.sideband_outbound_request(/*request_index*/ 0).await["type"].as_str(), Some("session.update") @@ -1738,7 +1969,7 @@ async fn realtime_automatic_handoff_output_is_item_and_append_speaks() -> Result } #[tokio::test] -async fn webrtc_v2_assistant_output_without_handoff_reaches_realtime_context() -> Result<()> { +async fn websocket_v2_assistant_output_without_handoff_reaches_realtime_context() -> Result<()> { skip_if_no_network!(Ok(())); let final_answer = "long output ".repeat(1_000); @@ -1769,9 +2000,9 @@ async fn webrtc_v2_assistant_output_without_handoff_reaches_realtime_context() - .await?; let started = harness - .start_webrtc_realtime_with_codex_response_items("v=offer\r\n") + .start_websocket_realtime_with_codex_response_items() .await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V2); let request_id = harness .mcp @@ -1818,10 +2049,10 @@ async fn webrtc_v2_assistant_output_without_handoff_reaches_realtime_context() - } #[tokio::test] -async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Result<()> { +async fn websocket_v2_forwards_audio_and_text_between_client_and_sideband() -> Result<()> { skip_if_no_network!(Ok(())); - // Phase 1: create a v2 WebRTC conversation whose sideband sends transcript + output audio + // Phase 1: create a v2 websocket conversation whose sideband sends transcript + output audio // after the client has had a chance to append input. let mut harness = RealtimeE2eHarness::new( RealtimeTestVersion::V2, @@ -1846,13 +2077,13 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; // Phase 2: drive app-server as the client would: append audio, append text, then receive // transcript/audio notifications that came from the sideband socket. - let thread_id = started.started.thread_id.clone(); + let thread_id = started.thread_id.clone(); harness.append_audio(thread_id.clone()).await?; harness.append_text(thread_id, "hello").await?; @@ -1901,7 +2132,7 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu /// Text input is append-only, so app-server should send the user message without /// requesting a new realtime response. #[tokio::test] -async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Result<()> { +async fn websocket_v2_text_input_is_append_only_while_response_is_active() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: script a server-side response that becomes active after the first @@ -1930,8 +2161,8 @@ async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Resul ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); // From here on, `sideband_outbound_request(n)` reads outbound messages to // the fake Realtime API sideband websocket. These are not client-facing @@ -1940,7 +2171,7 @@ async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Resul // Phase 2: send the first text turn. Text input is append-only, so this // sends only the user text item. - let thread_id = started.started.thread_id.clone(); + let thread_id = started.thread_id.clone(); harness.append_text(thread_id.clone(), "first").await?; assert_v2_user_text_item( &harness.sideband_outbound_request(/*request_index*/ 1).await, @@ -1975,7 +2206,7 @@ async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Resul /// Regression coverage for append-only Realtime V2 text input when the active /// response is cancelled instead of completed. #[tokio::test] -async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Result<()> { +async fn websocket_v2_text_input_is_append_only_when_response_is_cancelled() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: script a server-side response that becomes active after the first @@ -1998,13 +2229,13 @@ async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Res ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; // Phase 2: send the first text turn. Text input is append-only, so this // sends only the user text item. - let thread_id = started.started.thread_id.clone(); + let thread_id = started.thread_id.clone(); harness.append_text(thread_id.clone(), "first").await?; assert_v2_user_text_item( &harness.sideband_outbound_request(/*request_index*/ 1).await, @@ -2036,8 +2267,7 @@ async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Res /// output to realtime and then requests a new `response.create` so realtime can /// react to that final output. #[tokio::test] -async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_output() -> Result<()> -{ +async fn websocket_v2_background_agent_returns_function_output() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: script a v2 background agent function call and a delegated Responses turn that @@ -2086,8 +2316,8 @@ async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_out ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); // Phase 2: wait for the delegated turn lifecycle kicked off by the v2 function-call item. let turn_started = harness @@ -2134,7 +2364,7 @@ async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_out /// task. App-server acknowledges that steering message to realtime and then /// emits `response.create` so realtime can speak that acknowledgement. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn webrtc_v2_background_agent_steering_ack_requests_response_create() -> Result<()> { +async fn websocket_v2_background_agent_steering_ack_requests_response_create() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: gate the delegated Responses turn from the first tool call so @@ -2174,8 +2404,8 @@ async fn webrtc_v2_background_agent_steering_ack_requests_response_create() -> R ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; let turn_started = harness .read_notification::("turn/started") @@ -2217,7 +2447,7 @@ async fn webrtc_v2_background_agent_steering_ack_requests_response_create() -> R } #[tokio::test] -async fn webrtc_v2_background_agent_progress_is_sent_before_function_output() -> Result<()> { +async fn websocket_v2_background_agent_progress_is_sent_before_function_output() -> Result<()> { skip_if_no_network!(Ok(())); let mut harness = RealtimeE2eHarness::new( @@ -2236,8 +2466,8 @@ async fn webrtc_v2_background_agent_progress_is_sent_before_function_output() -> ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); let turn_completed = harness .read_notification::("turn/completed") @@ -2259,7 +2489,7 @@ async fn webrtc_v2_background_agent_progress_is_sent_before_function_output() -> } #[tokio::test] -async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<()> { +async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: keep the two mocked OpenAI conversations explicit. The realtime sideband only @@ -2293,7 +2523,7 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<( ) .await?; - let _ = harness.start_webrtc_realtime("v=offer\r\n").await?; + let _ = harness.start_websocket_realtime().await?; // Phase 2: observe the delegated background agent turn executing the requested shell command. let started_command = wait_for_started_command_execution(&mut harness.mcp).await?; @@ -2321,9 +2551,12 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<( // Phase 3: verify the shell output reached Responses and the final delegated answer returned // to realtime as a single function-call-output item. - let turn_completed = harness - .read_notification::("turn/completed") - .await?; + let turn_completed = read_notification_with_timeout::( + &mut harness.mcp, + "turn/completed", + DELEGATED_SHELL_TURN_TIMEOUT, + ) + .await?; assert_eq!(turn_completed.thread_id, harness.thread_id); let requests = harness.main_loop_responses_requests().await?; @@ -2349,7 +2582,7 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn webrtc_v2_tool_call_does_not_block_sideband_audio() -> Result<()> { +async fn websocket_v2_tool_call_does_not_block_sideband_audio() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: gate the delegated Responses stream so the sideband can send audio while the tool @@ -2392,7 +2625,7 @@ async fn webrtc_v2_tool_call_does_not_block_sideband_audio() -> Result<()> { ) .await?; - let _ = harness.start_webrtc_realtime("v=offer\r\n").await?; + let _ = harness.start_websocket_realtime().await?; let _ = harness .read_notification::("turn/started") .await?; @@ -2467,9 +2700,10 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id, model: None, output_modality: RealtimeOutputModality::Audio, @@ -2532,9 +2766,10 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -2566,9 +2801,17 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { async fn read_notification( mcp: &mut TestAppServer, method: &str, +) -> Result { + read_notification_with_timeout(mcp, method, DEFAULT_TIMEOUT).await +} + +async fn read_notification_with_timeout( + mcp: &mut TestAppServer, + method: &str, + timeout_duration: Duration, ) -> Result { let notification = timeout( - DEFAULT_TIMEOUT, + timeout_duration, mcp.read_stream_until_notification_message(method), ) .await??; @@ -2784,7 +3027,10 @@ fn assert_call_create_multipart( session: &str, ) -> Result<()> { assert_eq!(request.url.path(), "/v1/realtime/calls"); - assert_eq!(request.url.query(), None); + assert_eq!( + request.url.query(), + Some("intent=quicksilver&architecture=avas") + ); assert_eq!( request .headers diff --git a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs new file mode 100644 index 000000000000..59748a19801e --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs @@ -0,0 +1,165 @@ +use anyhow::Result; +use app_test_support::ChatGptIdTokenClaims; +use app_test_support::TestAppServer; +use app_test_support::encode_id_token; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses; +use serde_json::Value; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20); +const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174010"; + +#[tokio::test] +async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Result<()> { + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(250)) + .set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": "plugin_github", + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "GitHub"} + }] + })), + ) + .expect(1) + .mount(&server) + .await; + let response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]); + let responses_mock = responses::mount_sse_once(&server, response).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &apps_server.chatgpt_base_url, + )?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!( + "{config}\n[features]\napps = true\nplugins = true\nremote_plugin = true\ntool_suggest = true\n" + ), + )?; + + let sqlite_home = codex_home.path().to_string_lossy(); + let mut app_server = TestAppServer::new_without_managed_config_with_env( + codex_home.path(), + &[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id(WORKSPACE_ID), + )?; + let login_id = app_server + .send_chatgpt_auth_tokens_login_request( + access_token, + WORKSPACE_ID.to_string(), + Some("pro".to_string()), + ) + .await?; + let login_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(login_id)), + ) + .await??; + assert_eq!( + to_response::(login_response)?, + LoginAccountResponse::ChatgptAuthTokens {} + ); + + let thread_id = app_server + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(thread_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; + + let turn_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "suggest a plugin".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = responses_mock.requests(); + let request = requests + .iter() + .find(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text.contains("suggest a plugin")) + }) + .expect("turn request"); + let contextual_user_message = request.message_input_texts("user").join("\n"); + assert!(contextual_user_message.contains("")); + assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)")); + let body = request.body_json(); + let tool_names = body + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .collect::>(); + assert!(tool_names.contains(&"request_plugin_install")); + assert!(!tool_names.contains(&"list_available_plugins_to_install")); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index 43076d1649f1..30f7f73beb77 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -271,7 +271,15 @@ async fn listen_off_honors_persisted_remote_control_enable() -> Result<()> { .await?; let _app_server = TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; - timeout(STARTUP_TIMEOUT, listener.accept()).await??; + let request = timeout(STARTUP_TIMEOUT, read_http_request(&listener)).await??; + assert!( + request + .request_line + .starts_with("GET /backend-api/wham/remote/control/server ") + || request + .request_line + .starts_with("POST /backend-api/wham/remote/control/server/refresh ") + ); Ok(()) } @@ -852,9 +860,15 @@ impl BlockingRemoteControlBackend { { return; } - let Ok(_websocket) = listener.accept().await else { + let Ok(request) = read_http_request(&listener).await else { return; }; + if !request + .request_line + .starts_with("GET /backend-api/wham/remote/control/server ") + { + return; + } std::future::pending::<()>().await; } Err(err) => { @@ -1030,36 +1044,44 @@ async fn read_enroll_request(listener: &TcpListener) -> Result<(String, BufReade } async fn read_http_request(listener: &TcpListener) -> Result { - let (stream, _) = listener.accept().await?; - let mut reader = BufReader::new(stream); - - let mut request_line = String::new(); - reader.read_line(&mut request_line).await?; - let mut content_length = 0; loop { - let mut line = String::new(); - reader.read_line(&mut line).await?; - if line == "\r\n" { - break; + let (stream, _) = listener.accept().await?; + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + let mut content_length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line == "\r\n" { + break; + } + if let Some(value) = line + .trim_end() + .strip_prefix("content-length:") + .or_else(|| line.trim_end().strip_prefix("Content-Length:")) + { + content_length = value.trim().parse::()?; + } } - if let Some(value) = line - .trim_end() - .strip_prefix("content-length:") - .or_else(|| line.trim_end().strip_prefix("Content-Length:")) - { - content_length = value.trim().parse::()?; + let mut body = vec![0; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).await?; + } + + let request_line = request_line.trim_end().to_string(); + if request_line.starts_with("GET ") && request_line.contains("/v1/models?") { + respond_with_json(reader.into_inner(), serde_json::json!({ "models": [] })).await?; + continue; } - } - let mut body = vec![0; content_length]; - if content_length > 0 { - reader.read_exact(&mut body).await?; - } - Ok(HttpRequest { - request_line: request_line.trim_end().to_string(), - body: String::from_utf8(body)?, - reader, - }) + return Ok(HttpRequest { + request_line, + body: String::from_utf8(body)?, + reader, + }); + } } async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result<()> { 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 baf11aff5ee8..6deec70ffee4 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/skills__list.rs b/codex-rs/app-server/tests/suite/v2/skills__list.rs index 491c0608507a..ef7ee6c1d127 100644 --- a/codex-rs/app-server/tests/suite/v2/skills__list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills__list.rs @@ -789,6 +789,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( base_instructions: None, developer_instructions: None, personality: None, + multi_agent_mode: None, ephemeral: None, session_start_source: None, thread_source: None, 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 21dae50d6f17..00b90e64d839 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 @@ -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 fe5af8392379..e7b314288a97 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1460,6 +1460,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( @@ -1897,6 +1898,89 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_sort_recency_at_uses_state_db_order_with_provider_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-01-01T10-00-00", + "2025-01-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_new = create_fake_rollout( + codex_home.path(), + "2025-01-01T11-00-00", + "2025-01-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), + "2025-01-03T00:00:00Z", + )?; + + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let rollout_config = codex_rollout::RolloutConfig { + codex_home: codex_home.path().to_path_buf(), + sqlite_home: codex_home.path().to_path_buf(), + cwd: codex_home.path().to_path_buf(), + model_provider_id: "mock_provider".to_string(), + generate_memories: false, + }; + codex_core::RolloutRecorder::list_threads( + Some(state_db.clone()), + &rollout_config, + /*page_size*/ 10, + /*cursor*/ None, + codex_core::ThreadSortKey::CreatedAt, + codex_core::SortDirection::Desc, + codex_core::INTERACTIVE_SESSION_SOURCES.as_slice(), + /*model_providers*/ None, + /*cwd_filters*/ None, + "mock_provider", + /*search_term*/ None, + ) + .await?; + state_db + .touch_thread_recency_at( + ThreadId::from_string(&id_new)?, + DateTime::::from_timestamp(1_800_000_000, 0).expect("timestamp"), + ) + .await?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::RecencyAt), + /*archived*/ None, + ) + .await?; + + assert_eq!( + data.iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + vec![id_new.as_str(), id_old.as_str()] + ); + assert!(data.iter().all(|thread| thread.recency_at.is_some())); + + Ok(()) +} + #[tokio::test] async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { let codex_home = TempDir::new()?; 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 a6ed5e4a636c..58990aaf04e0 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 e67184bf01d3..ea7a1a9d4695 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpToolCallAppContext; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::RequestId; @@ -33,6 +34,7 @@ use codex_app_server_protocol::ThreadGoalClearResponse; use codex_app_server_protocol::ThreadGoalSetResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadReadParams; @@ -80,6 +82,7 @@ use codex_rollout::append_rollout_item_to_path; use codex_rollout::read_session_meta_line; use codex_state::StateRuntime; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -286,7 +289,8 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul .. } = to_response::(start_resp)?; let project_agents = AbsolutePathBuf::try_from(project_agents)?; - assert_eq!(instruction_sources, vec![project_agents.clone()]); + let project_agents_source = LegacyAppPathString::from_abs_path(&project_agents); + assert_eq!(instruction_sources, vec![project_agents_source.clone()]); let turn_id = mcp .send_turn_start_request(TurnStartParams { @@ -328,7 +332,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul .. } = to_response::(resume_resp)?; - assert_eq!(instruction_sources, vec![project_agents]); + assert_eq!(instruction_sources, vec![project_agents_source]); Ok(()) } @@ -464,6 +468,95 @@ async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let codex_home_path = normalized_existing_path(codex_home.path())?; + create_config_toml(&codex_home_path, &server.uri())?; + let config_path = codex_home_path.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let sqlite_home = codex_home_path + .as_path() + .to_str() + .expect("test codex home should be utf-8"); + let mut mcp = TestAppServer::new_without_managed_config_with_env( + &codex_home_path, + &[("CODEX_SQLITE_HOME", Some(sqlite_home))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, cwd, .. } = to_response::(start_resp)?; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id.clone(), + "objective": "keep the goal-first thread visible", + "status": "paused", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let list_id = mcp + .send_raw_request( + "thread/list", + Some(json!({ + "limit": 10, + "modelProviders": ["mock_provider"], + "sourceKinds": ["vscode"], + "archived": false, + "cwd": cwd.as_path().to_string_lossy().to_string(), + "useStateDbOnly": true, + })), + ) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let list: ThreadListResponse = to_response(list_resp)?; + assert_eq!( + list.data + .iter() + .map(|thread| &thread.id) + .collect::>(), + vec![&thread.id] + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -639,6 +732,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( .expect("remote resume should include redacted MCP item"); let ThreadItem::McpToolCall { arguments, + app_context, result, error, .. @@ -647,6 +741,14 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( unreachable!("matched MCP item"); }; assert_eq!(arguments, &json!("[redacted]")); + assert_eq!( + app_context, + &Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }) + ); let result = result.as_ref().expect("redacted MCP result"); assert_eq!( result.content, @@ -680,12 +782,23 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) .expect("normal resume should include MCP item"); let ThreadItem::McpToolCall { - arguments, result, .. + arguments, + app_context, + result, + .. } = normal_mcp_item else { unreachable!("matched MCP item"); }; assert_eq!(arguments, &json!({"secret":"argument"})); + assert_eq!( + app_context, + &Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }) + ); let result = result.as_ref().expect("normal MCP result"); assert_eq!( result.content, @@ -787,7 +900,9 @@ fn append_resume_redaction_history( tool: "lookup".to_string(), arguments: Some(json!({"secret":"argument"})), }, + connector_id: Some("calendar".to_string()), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + link_id: Some("link_calendar".to_string()), plugin_id: None, duration: Duration::from_millis(8), result: Ok(CallToolResult { @@ -1939,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, @@ -2200,6 +2316,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.updated_at, before_resume.updated_at); + assert_eq!(thread.recency_at, before_resume.recency_at); assert_eq!(thread.status, ThreadStatus::Idle); let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; @@ -2234,7 +2351,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id, + thread_id: thread_id.clone(), input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -2247,6 +2364,29 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { + thread: after_turn_start, + .. + } = to_response::(read_resp)?; + assert!(after_turn_start.recency_at > before_resume.recency_at); + timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), @@ -2449,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() }) @@ -2596,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(), @@ -3457,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 29dbee26d17d..9ad25ca992be 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 @@ -22,7 +22,10 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::test_support::all_model_presets; +use codex_features::Feature; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; @@ -94,6 +97,109 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Ok(()) } +#[tokio::test] +async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + (1..=2) + .map(|index| { + responses::sse(vec![ + responses::ev_response_created(&format!("resp-{index}")), + responses::ev_assistant_message(&format!("msg-{index}"), "done"), + responses::ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), + /*auto_compact_limit*/ 200_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + start_text_turn(&mut mcp, thread.id.clone()).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!(response_mock.requests().len(), 1); + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }, + ) + .await?; + assert_eq!( + response_mock.requests().len(), + 1, + "settings-only update should not start a model request" + ); + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!( + updated.thread_settings.multi_agent_mode, + MultiAgentMode::Proactive + ); + + start_text_turn(&mut mcp, thread.id).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + let first_developer_texts = requests[0].message_input_texts("developer"); + let second_developer_texts = requests[1].message_input_texts("developer"); + assert_eq!( + first_developer_texts + .iter() + .filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG)) + .count(), + 1 + ); + assert_eq!( + second_developer_texts + .iter() + .filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG)) + .count(), + 2 + ); + assert_eq!( + second_developer_texts + .iter() + .filter(|text| text.contains("Proactive multi-agent delegation is active.")) + .count(), + 1 + ); + assert_eq!( + second_developer_texts + .iter() + .filter(|text| text + .contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents")) + .count(), + 1 + ); + Ok(()) +} + #[tokio::test] async fn thread_settings_update_cwd_retargets_default_environment() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 82a2b6b2c070..dcfeb4fa94b2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -293,7 +293,10 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result .send_thread_start_request(ThreadStartParams { environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_home.path().to_path_buf().try_into()?, + cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from( + codex_home.path().to_path_buf(), + )? + .into(), }]), ..Default::default() }) @@ -312,6 +315,40 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: serde_json::from_value(json!("relative"))?, + }]), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "invalid cwd for environment `local`: path `relative` does not use absolute POSIX or Windows path syntax" + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -344,7 +381,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( let instruction_sources = instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, @@ -391,7 +428,7 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re let instruction_sources = instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(); let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize( global_agents_path, @@ -437,7 +474,7 @@ async fn thread_start_without_selected_environment_includes_only_global_instruct assert_eq!( instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(), vec![normalize_path_for_comparison(std::fs::canonicalize( global_agents_path, diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index b8349a77f192..e922bd13c14e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), + mcp_server_openai_form_elicitation: false, }), ), ) 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 7267ab073ac9..46a989545c87 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 eab14dd035c5..9bab88c9b478 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -69,13 +69,16 @@ use codex_features::FEATURES; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -1326,7 +1329,10 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result }], environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_home.path().to_path_buf().try_into()?, + cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from( + codex_home.path().to_path_buf(), + )? + .into(), }]), ..Default::default() }) @@ -1743,6 +1749,213 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::MultiAgentV2, true)]), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let developer_texts = response_mock + .single_request() + .message_input_texts("developer"); + assert!(developer_texts.iter().any(|text| { + text.contains("") + && text.contains("Proactive multi-agent delegation is active.") + })); + assert!(!developer_texts.iter().any(|text| { + text.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents") + })); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::MultiAgentV2, true)]), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { + thread, + multi_agent_mode, + .. + } = to_response::(thread_resp)?; + assert_eq!(multi_agent_mode, MultiAgentMode::Proactive); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let developer_texts = response_mock + .single_request() + .message_input_texts("developer"); + assert!( + developer_texts.iter().any(|text| { + text.contains(MULTI_AGENT_MODE_OPEN_TAG) + && text.contains("Proactive multi-agent delegation is active.") + }), + "expected proactive multi-agent mode instructions in developer input, got {developer_texts:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_reports_multi_agent_mode() -> Result<()> { + skip_if_no_network!(Ok(())); + + let cases = [ + ( + BTreeMap::from([(Feature::MultiAgentV2, true)]), + Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, + ), + ( + BTreeMap::from([(Feature::MultiAgentV2, true)]), + Some(MultiAgentMode::None), + MultiAgentMode::None, + ), + ( + BTreeMap::new(), + Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, + ), + ( + BTreeMap::from([(Feature::MultiAgentV2, true)]), + None, + MultiAgentMode::ExplicitRequestOnly, + ), + ]; + + for (features, requested_multi_agent_mode, expected_multi_agent_mode) in cases { + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never", &features)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + multi_agent_mode: requested_multi_agent_mode, + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let response = to_response::(thread_resp)?; + + assert_eq!(response.multi_agent_mode, expected_multi_agent_mode); + } + + Ok(()) +} + #[tokio::test] async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { skip_if_no_network!(Ok(())); @@ -2061,6 +2274,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { panic!("expected CommandExecutionRequestApproval request"); }; assert_eq!(params.item_id, "call1"); + assert_eq!(params.environment_id.as_deref(), Some("local")); let resolved_request_id = request_id.clone(); // Approve and wait for task completion @@ -2360,6 +2574,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { personality: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, }) .await?; timeout( @@ -2399,6 +2614,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { personality: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, }) .await?; timeout( @@ -2433,7 +2649,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { else { unreachable!("loop ensures we break on command execution items"); }; - assert_eq!(cwd.as_path(), second_cwd.as_path()); + assert_eq!(cwd.as_str(), second_cwd.to_string_lossy().as_ref()); let expected_command = format_with_current_shell_display("echo second turn"); assert_eq!(command, expected_command); assert_eq!(status, CommandExecutionStatus::InProgress); @@ -2667,7 +2883,7 @@ async fn run_environment_selection_case( .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), - environments: environment_params(case.sticky, workspace)?, + environments: environment_params(case.sticky, workspace), ..Default::default() }) .await?; @@ -2686,7 +2902,7 @@ async fn run_environment_selection_case( text: format!("run {}", case.name), text_elements: Vec::new(), }], - environments: environment_params(case.turn, workspace)?, + environments: environment_params(case.turn, workspace), cwd: Some(workspace.to_path_buf()), model: Some("mock-model".to_string()), ..Default::default() @@ -2733,21 +2949,15 @@ async fn run_environment_selection_case( Ok(()) } -fn environment_params( - ids: Option<&[&str]>, - cwd: &Path, -) -> Result>> { +fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option> { ids.map(|ids| { ids.iter() - .map(|id| { - Ok(TurnEnvironmentParams { - environment_id: (*id).to_string(), - cwd: cwd.to_path_buf().try_into()?, - }) + .map(|id| TurnEnvironmentParams { + environment_id: (*id).to_string(), + cwd: cwd.abs().into(), }) .collect() }) - .transpose() } #[tokio::test] @@ -2769,7 +2979,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { create_apply_patch_sse_response(patch, "patch-call")?, create_final_assistant_message_sse_response("patch applied")?, ]; - let server = create_mock_responses_server_sequence(responses).await; + let server = create_mock_responses_server_sequence_unchecked(responses).await; create_config_toml( &codex_home, &server.uri(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 15072c1ccfb2..71da3eab9e9b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; @@ -167,7 +168,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { assert!(command.contains("/bin/sh -c")); assert!(command.contains("sleep 0.01")); assert!(command.contains(&release_marker.display().to_string())); - assert_eq!(cwd.as_path(), workspace.as_path()); + assert_eq!(cwd.as_str(), workspace.to_string_lossy().as_ref()); mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) .await?; @@ -556,6 +557,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let mut approved_subcommand_strings = Vec::new(); let mut approved_subcommand_ids = Vec::new(); let mut saw_parent_approval = false; + let mut early_parent_completion = None; + let mut early_turn_completion = None; let target_decisions = [ CommandExecutionApprovalDecision::Accept, CommandExecutionApprovalDecision::Cancel, @@ -565,11 +568,40 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let second_file_str = second_file.to_string_lossy().into_owned(); let parent_shell_hint = format!("&& {}", &first_file_str); while target_decision_index < target_decisions.len() || !saw_parent_approval { - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Request(jsonrpc_request) = message else { + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "item/completed" => { + let completed: ItemCompletedNotification = serde_json::from_value( + notification.params.clone().expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { id, .. } = &completed.item + && id == "call-zsh-fork-subcommand-decline" + { + early_parent_completion = Some(completed.item); + break; + } + } + "turn/completed" => { + let completed: TurnCompletedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("turn/completed params must be present"), + )?; + if completed.thread_id == thread.id && completed.turn.id == turn.id { + early_turn_completion = Some(completed); + break; + } + } + _ => {} + } + continue; + }; + let server_req: ServerRequest = jsonrpc_request.try_into()?; let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { panic!("expected CommandExecutionRequestApproval request"); @@ -631,6 +663,67 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() .await?; } + if (target_decision_index < target_decisions.len() || !saw_parent_approval) + && cfg!(target_os = "macos") + { + if let Some(parent_completed_command_execution) = early_parent_completion { + let ThreadItem::CommandExecution { + id, + status, + aggregated_output, + exit_code, + .. + } = parent_completed_command_execution + else { + unreachable!("early completion is only set from a command execution item"); + }; + assert_eq!(id, "call-zsh-fork-subcommand-decline"); + assert!( + matches!( + status, + CommandExecutionStatus::Declined | CommandExecutionStatus::Failed + ), + "unexpected early completion status: {status:?}" + ); + if status == CommandExecutionStatus::Failed { + assert_eq!(exit_code, Some(1)); + } + if let Some(output) = aggregated_output.as_deref() { + assert!( + output.contains("Operation not permitted") + || output.contains("sandbox denied exec error"), + "unexpected aggregated output: {output}" + ); + } + let completed_notif = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert!(matches!( + completed.turn.status, + TurnStatus::Interrupted | TurnStatus::Completed | TurnStatus::Failed + )); + return Ok(()); + } + if let Some(completed) = early_turn_completion { + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert!(matches!( + completed.turn.status, + TurnStatus::Interrupted | TurnStatus::Completed | TurnStatus::Failed + )); + return Ok(()); + } + } + assert!( saw_parent_approval, "expected parent shell approval request" diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 8701750fbef2..de9c735c597d 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -1,9 +1,7 @@ use std::collections::HashMap; -use std::path::Path; use std::sync::LazyLock; use codex_exec_server::ExecutorFileSystem; -use codex_utils_absolute_path::AbsolutePathBuf; use tree_sitter::Parser; use tree_sitter::Query; use tree_sitter::QueryCursor; @@ -21,6 +19,7 @@ use crate::parser::Hunk; use crate::parser::ParseError; use crate::parser::parse_patch; use crate::unified_diff_from_chunks; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use std::str::Utf8Error; use tree_sitter::LanguageError; @@ -51,15 +50,17 @@ pub enum ExtractHeredocError { FailedToFindHeredocBody, } -fn classify_shell_name(shell: &str) -> Option { - std::path::Path::new(shell) - .file_stem() - .and_then(|name| name.to_str()) - .map(str::to_ascii_lowercase) +fn classify_shell_name(shell: &str, convention: PathConvention) -> Option { + let basename = convention.path_segments(shell).next_back()?; + let stem = basename + .rsplit_once('.') + .and_then(|(stem, _extension)| (!stem.is_empty()).then_some(stem)) + .unwrap_or(basename); + Some(stem.to_ascii_lowercase()) } -fn classify_shell(shell: &str, flag: &str) -> Option { - classify_shell_name(shell).and_then(|name| match name.as_str() { +fn classify_shell(shell: &str, flag: &str, convention: PathConvention) -> Option { + classify_shell_name(shell, convention).and_then(|name| match name.as_str() { "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { Some(ApplyPatchShell::PowerShell) @@ -69,20 +70,24 @@ fn classify_shell(shell: &str, flag: &str) -> Option { }) } -fn can_skip_flag(shell: &str, flag: &str) -> bool { - classify_shell_name(shell).is_some_and(|name| { +fn can_skip_flag(shell: &str, flag: &str, convention: PathConvention) -> bool { + classify_shell_name(shell, convention).is_some_and(|name| { matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile") }) } -fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> { +fn parse_shell_script<'a>(argv: &'a [String], cwd: &PathUri) -> Option<(ApplyPatchShell, &'a str)> { + let convention = cwd.infer_path_convention()?; match argv { - [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| { + [shell, flag, script] => classify_shell(shell, flag, convention).map(|shell_type| { let script = script.as_str(); (shell_type, script) }), - [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => { - classify_shell(shell, flag).map(|shell_type| { + [shell, skip_flag, flag, script] => { + if !can_skip_flag(shell, skip_flag, convention) { + return None; + } + classify_shell(shell, flag, convention).map(|shell_type| { let script = script.as_str(); (shell_type, script) }) @@ -103,7 +108,8 @@ fn extract_apply_patch_from_shell( } // TODO: make private once we remove tests in lib.rs -pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { +/// `cwd` supplies the path convention used to interpret the shell executable in `argv`. +pub fn maybe_parse_apply_patch(argv: &[String], cwd: &PathUri) -> MaybeApplyPatch { match argv { // Direct invocation: apply_patch [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { @@ -111,7 +117,7 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { Err(e) => MaybeApplyPatch::PatchParseError(e), }, // Shell heredoc form: (optional `cd &&`) apply_patch <<'EOF' ... - _ => match parse_shell_script(argv) { + _ => match parse_shell_script(argv, cwd) { Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) { Ok((body, workdir)) => match parse_patch(&body) { Ok(mut source) => { @@ -130,11 +136,11 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { } } -/// cwd must be an absolute path so that we can resolve relative paths in the -/// patch. +/// `cwd` must identify an absolute environment-native path so relative patch paths can be +/// resolved without projecting them onto the app-server or exec-server host. pub async fn maybe_parse_apply_patch_verified( argv: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, ) -> MaybeApplyPatchVerified { @@ -145,13 +151,13 @@ pub async fn maybe_parse_apply_patch_verified( { return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); } - if let Some((_, script)) = parse_shell_script(argv) + if let Some((_, script)) = parse_shell_script(argv, cwd) && parse_patch(script).is_ok() { return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); } - match maybe_parse_apply_patch(argv) { + match maybe_parse_apply_patch(argv, cwd) { MaybeApplyPatch::Body(args) => verify_apply_patch_args(args, cwd, fs, sandbox).await, MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()), @@ -161,10 +167,22 @@ pub async fn maybe_parse_apply_patch_verified( pub async fn verify_apply_patch_args( args: ApplyPatchArgs, - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, ) -> MaybeApplyPatchVerified { + match try_verify_apply_patch_args(args, cwd, fs, sandbox).await { + Ok(action) => MaybeApplyPatchVerified::Body(action), + Err(err) => MaybeApplyPatchVerified::CorrectnessError(err), + } +} + +async fn try_verify_apply_patch_args( + args: ApplyPatchArgs, + cwd: &PathUri, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, +) -> Result { let ApplyPatchArgs { patch, hunks, @@ -173,35 +191,24 @@ pub async fn verify_apply_patch_args( } = args; let effective_cwd = workdir .as_ref() - .map(|dir| cwd.join(Path::new(dir))) + .map(|dir| cwd.join(dir)) + .transpose()? .unwrap_or_else(|| cwd.clone()); let mut changes = HashMap::new(); for hunk in hunks { - let path = hunk.resolve_path(&effective_cwd); + let path = hunk.resolve_path(&effective_cwd)?; match hunk { Hunk::AddFile { contents, .. } => { - changes.insert( - path.into_path_buf(), - ApplyPatchFileChange::Add { content: contents }, - ); + changes.insert(path, ApplyPatchFileChange::Add { content: contents }); } Hunk::DeleteFile { .. } => { - let path_uri = PathUri::from_abs_path(&path); - let content = match fs.read_file_text(&path_uri, sandbox).await { - Ok(content) => content, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError( - ApplyPatchError::IoError(IoError { - context: format!("Failed to read {}", path.display()), - source: e, - }), - ); - } - }; - changes.insert( - path.into_path_buf(), - ApplyPatchFileChange::Delete { content }, - ); + let content = fs.read_file_text(&path, sandbox).await.map_err(|source| { + ApplyPatchError::IoError(IoError { + context: format!("Failed to read {}", path.inferred_native_path_string()), + source, + }) + })?; + changes.insert(path, ApplyPatchFileChange::Delete { content }); } Hunk::UpdateFile { move_path, chunks, .. @@ -210,24 +217,21 @@ pub async fn verify_apply_patch_args( unified_diff, content: contents, .. - } = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await { - Ok(diff) => diff, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError(e); - } - }; + } = unified_diff_from_chunks(&path, &chunks, fs, sandbox).await?; changes.insert( - path.into_path_buf(), + path, ApplyPatchFileChange::Update { unified_diff, - move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()), + move_path: move_path + .map(|path| effective_cwd.join(&path.to_string_lossy())) + .transpose()?, new_content: contents, }, ); } } } - MaybeApplyPatchVerified::Body(ApplyPatchAction { + Ok(ApplyPatchAction { changes, patch, cwd: effective_cwd, @@ -392,7 +396,6 @@ mod tests { use crate::unified_diff_from_chunks; use assert_matches::assert_matches; use codex_exec_server::LOCAL_FS; - use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -448,8 +451,22 @@ mod tests { }] } + #[track_caller] fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { - match maybe_parse_apply_patch(&args) { + assert_match_args_with_cwd( + args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + expected_workdir, + ); + } + + #[track_caller] + fn assert_match_args_with_cwd( + args: Vec, + cwd: &PathUri, + expected_workdir: Option<&str>, + ) { + match maybe_parse_apply_patch(&args, cwd) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { assert_eq!(workdir.as_deref(), expected_workdir); assert_eq!(hunks, expected_single_add()); @@ -458,6 +475,7 @@ mod tests { } } + #[track_caller] fn assert_match(script: &str, expected_workdir: Option<&str>) { let args = args_bash(script); assert_match_args(args, expected_workdir); @@ -466,7 +484,10 @@ mod tests { fn assert_not_match(script: &str) { let args = args_bash(script); assert_matches!( - maybe_parse_apply_patch(&args), + maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ), MaybeApplyPatch::NotApplyPatch ); } @@ -479,7 +500,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -496,7 +517,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -516,7 +537,10 @@ mod tests { "#, ]); - match maybe_parse_apply_patch(&args) { + match maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, @@ -541,7 +565,10 @@ mod tests { "#, ]); - match maybe_parse_apply_patch(&args) { + match maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, @@ -580,7 +607,10 @@ mod tests { PATCH"#, ]); - match maybe_parse_apply_patch(&args) { + match maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { assert_eq!(workdir, None); assert_eq!( @@ -614,6 +644,21 @@ PATCH"#, assert_match_args(args_pwsh(&script), /*expected_workdir*/ None); } + #[tokio::test] + async fn test_apply_patch_interception_uses_cwd_convention_for_windows_pwsh_path() { + let script = heredoc_script(""); + assert_match_args_with_cwd( + strs_to_strings(&[ + r"C:\Program Files\PowerShell\7\pwsh.exe", + "-NoProfile", + "-Command", + &script, + ]), + &PathUri::parse("file:///C:/windows").expect("valid Windows test cwd"), + /*expected_workdir*/ None, + ); + } + #[tokio::test] async fn test_cmd_heredoc_with_cd() { let script = heredoc_script("cd foo && "); @@ -707,9 +752,9 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ @@ -747,9 +792,9 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ @@ -787,7 +832,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + &PathUri::from_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -799,7 +844,8 @@ PATCH"#, result, MaybeApplyPatchVerified::Body(ApplyPatchAction { changes: HashMap::from([( - session_dir.path().join(relative_path), + PathUri::from_path(session_dir.path().join(relative_path)) + .expect("absolute test path"), ApplyPatchFileChange::Update { unified_diff: r#"@@ -1 +1 @@ -session directory content @@ -811,7 +857,7 @@ PATCH"#, }, )]), patch: argv[1].clone(), - cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + cwd: PathUri::from_path(session_dir.path()).expect("absolute test path"), }) ); } @@ -841,7 +887,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + &PathUri::from_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -851,20 +897,23 @@ PATCH"#, other => panic!("expected verified body, got {other:?}"), }; - assert_eq!(action.cwd.as_path(), worktree_dir.as_path()); + assert_eq!( + action.cwd.to_abs_path().unwrap().as_path(), + worktree_dir.as_path() + ); - let source_path = worktree_dir.join(source_name); + let source_path = + PathUri::from_path(worktree_dir.join(source_name)).expect("absolute test path"); let change = action .changes() - .get(source_path.as_path()) + .get(&source_path) .expect("source file change present"); match change { ApplyPatchFileChange::Update { move_path, .. } => { - assert_eq!( - move_path.as_deref(), - Some(worktree_dir.join(dest_name).as_path()) - ); + let expected_move_path = + PathUri::from_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:?}"), } @@ -874,7 +923,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 = AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(); + let cwd = PathUri::from_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(), @@ -917,7 +966,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + &PathUri::from_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 386f1e53469f..f27610d6cc01 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -6,7 +6,6 @@ mod streaming_parser; use std::collections::HashMap; use std::io; -use std::path::Path; use std::path::PathBuf; use anyhow::Context; @@ -15,8 +14,8 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::RemoveOptions; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; +use codex_utils_path_uri::PathUriParseError; pub use parser::Hunk; pub use parser::ParseError; use parser::ParseError::*; @@ -50,6 +49,9 @@ pub enum ApplyPatchError { /// Error that occurs while computing replacements when applying patch chunks #[error("{0}")] ComputeReplacements(String), + /// A patch path could not be resolved as a path URI. + #[error(transparent)] + PathUri(#[from] PathUriParseError), /// A raw patch body was provided without an explicit `apply_patch` invocation. #[error( "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]" @@ -109,7 +111,7 @@ pub enum ApplyPatchFileChange { }, Update { unified_diff: String, - move_path: Option, + move_path: Option, /// new_content that will result after the unified_diff is applied. new_content: String, }, @@ -134,7 +136,7 @@ pub enum MaybeApplyPatchVerified { /// construction, all paths should be absolute paths. #[derive(Debug, PartialEq)] pub struct ApplyPatchAction { - changes: HashMap, + changes: HashMap, /// The raw patch argument that can be used to apply the patch. i.e., if the /// original arg was parsed in "lenient" mode with a @@ -142,7 +144,7 @@ pub struct ApplyPatchAction { pub patch: String, /// The working directory that was used to resolve relative paths in the patch. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, } impl ApplyPatchAction { @@ -151,18 +153,15 @@ impl ApplyPatchAction { } /// Returns the changes that would be made by applying the patch. - pub fn changes(&self) -> &HashMap { + pub fn changes(&self) -> &HashMap { &self.changes } /// Should be used exclusively for testing. (Not worth the overhead of /// creating a feature flag for this.) - pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self { + pub fn new_add_for_test(path: &PathUri, content: String) -> Self { #[expect(clippy::expect_used)] - let filename = path - .file_name() - .expect("path should not be empty") - .to_string_lossy(); + let filename = path.basename().expect("path should not be empty"); let patch = format!( r#"*** Begin Patch *** Update File: {filename} @@ -170,7 +169,7 @@ impl ApplyPatchAction { + {content} *** End Patch"#, ); - let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]); + let changes = HashMap::from([(path.clone(), ApplyPatchFileChange::Add { content })]); #[expect(clippy::expect_used)] Self { changes, @@ -276,7 +275,7 @@ impl ApplyPatchFailure { /// Applies the patch and prints the result to stdout/stderr. pub async fn apply_patch( patch: &str, - cwd: &AbsolutePathBuf, + cwd: &PathUri, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, @@ -315,7 +314,7 @@ pub async fn apply_patch( /// Applies hunks and continues to update stdout/stderr pub async fn apply_hunks( hunks: &[Hunk], - cwd: &AbsolutePathBuf, + cwd: &PathUri, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, @@ -361,7 +360,7 @@ pub struct AffectedPaths { /// Returns an error if the patch could not be applied. async fn apply_hunks_to_files( hunks: &[Hunk], - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, delta: &mut AppliedPatchDelta, @@ -388,26 +387,26 @@ async fn apply_hunks_to_files( }; } + // TODO(anp): Carry PathUri through committed patch deltas and the turn diff tracker. for hunk in hunks { let affected_path = hunk.path().to_path_buf(); - let path_abs = hunk.resolve_path(cwd); - let path_uri = PathUri::from_abs_path(&path_abs); + let path_uri = hunk.resolve_path(cwd)?; match hunk { Hunk::AddFile { contents, .. } => { let overwritten_content = - read_optional_file_text_for_delta(&path_abs, fs, sandbox, &mut delta.exact) + read_optional_file_text_for_delta(&path_uri, fs, sandbox, &mut delta.exact) .await; try_write!( write_file_with_missing_parent_retry( fs, - &path_abs, + &path_uri, contents.clone().into_bytes(), sandbox, ) .await ); delta.changes.push(AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Add { content: contents.clone(), overwritten_content, @@ -416,14 +415,19 @@ async fn apply_hunks_to_files( added.push(affected_path); } Hunk::DeleteFile { .. } => { - note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await; let deleted_content = fs.read_file_text(&path_uri, sandbox).await.ok(); if deleted_content.is_none() { delta.exact = false; } - ensure_not_directory(&path_abs, fs, sandbox) + ensure_not_directory(&path_uri, fs, sandbox) .await - .with_context(|| format!("Failed to delete file {}", path_abs.display()))?; + .with_context(|| { + format!( + "Failed to delete file {}", + path_uri.inferred_native_path_string() + ) + })?; if let Err(error) = fs .remove( &path_uri, @@ -434,10 +438,15 @@ async fn apply_hunks_to_files( sandbox, ) .await - .with_context(|| format!("Failed to delete file {}", path_abs.display())) + .with_context(|| { + format!( + "Failed to delete file {}", + path_uri.inferred_native_path_string() + ) + }) { delta.exact &= remove_failure_was_side_effect_free( - &path_abs, + &path_uri, deleted_content.as_deref(), fs, sandbox, @@ -447,7 +456,7 @@ async fn apply_hunks_to_files( } if let Some(content) = deleted_content { delta.changes.push(AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Delete { content }, }); } @@ -456,20 +465,20 @@ async fn apply_hunks_to_files( Hunk::UpdateFile { move_path, chunks, .. } => { - note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await; let AppliedPatch { original_contents, new_contents, - } = derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?; + } = derive_new_contents_from_chunks(&path_uri, chunks, fs, sandbox).await?; if let Some(dest) = move_path { - let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd); + let dest_uri = cwd.join(&dest.to_string_lossy())?; let overwritten_move_content = - read_optional_file_text_for_delta(&dest_abs, fs, sandbox, &mut delta.exact) + read_optional_file_text_for_delta(&dest_uri, fs, sandbox, &mut delta.exact) .await; try_write!( write_file_with_missing_parent_retry( fs, - &dest_abs, + &dest_uri, new_contents.clone().into_bytes(), sandbox, ) @@ -477,16 +486,19 @@ async fn apply_hunks_to_files( ); let dest_write_change_index = delta.changes.len(); delta.changes.push(AppliedPatchChange { - path: dest_abs.to_path_buf(), + path: dest_uri.to_path_buf(), change: AppliedPatchFileChange::Add { content: new_contents.clone(), overwritten_content: overwritten_move_content.clone(), }, }); - ensure_not_directory(&path_abs, fs, sandbox) + ensure_not_directory(&path_uri, fs, sandbox) .await .with_context(|| { - format!("Failed to remove original {}", path_abs.display()) + format!( + "Failed to remove original {}", + path_uri.inferred_native_path_string() + ) })?; if let Err(error) = fs .remove( @@ -499,11 +511,14 @@ async fn apply_hunks_to_files( ) .await .with_context(|| { - format!("Failed to remove original {}", path_abs.display()) + format!( + "Failed to remove original {}", + path_uri.inferred_native_path_string() + ) }) { delta.exact &= remove_failure_was_side_effect_free( - &path_abs, + &path_uri, Some(&original_contents), fs, sandbox, @@ -512,9 +527,9 @@ async fn apply_hunks_to_files( return Err(error); } delta.changes[dest_write_change_index] = AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Update { - move_path: Some(dest_abs.into_path_buf()), + move_path: Some(dest_uri.to_path_buf()), old_content: original_contents, overwritten_move_content, new_content: new_contents, @@ -527,11 +542,11 @@ async fn apply_hunks_to_files( .await .with_context(|| format!( "Failed to write file {}", - path_abs.display() + path_uri.inferred_native_path_string() )) ); delta.changes.push(AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Update { move_path: None, old_content: original_contents, @@ -552,12 +567,11 @@ async fn apply_hunks_to_files( } async fn ensure_not_directory( - path: &AbsolutePathBuf, + path: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result<()> { - let path_uri = PathUri::from_abs_path(path); - let metadata = fs.get_metadata(&path_uri, sandbox).await?; + let metadata = fs.get_metadata(path, sandbox).await?; if metadata.is_directory { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -568,15 +582,14 @@ async fn ensure_not_directory( } async fn remove_failure_was_side_effect_free( - path: &AbsolutePathBuf, + path: &PathUri, expected_content: Option<&str>, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> bool { - let path_uri = PathUri::from_abs_path(path); match expected_content { Some(expected_content) => fs - .read_file_text(&path_uri, sandbox) + .read_file_text(path, sandbox) .await .is_ok_and(|content| content == expected_content), None => false, @@ -584,14 +597,13 @@ async fn remove_failure_was_side_effect_free( } async fn read_optional_file_text_for_delta( - path: &AbsolutePathBuf, + path: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, exact: &mut bool, ) -> Option { note_existing_path_delta_support(path, fs, sandbox, exact).await; - let path_uri = PathUri::from_abs_path(path); - match fs.read_file_text(&path_uri, sandbox).await { + match fs.read_file_text(path, sandbox).await { Ok(content) => Some(content), Err(source) if source.kind() == io::ErrorKind::NotFound => None, Err(_) => { @@ -602,13 +614,12 @@ async fn read_optional_file_text_for_delta( } async fn note_existing_path_delta_support( - path: &AbsolutePathBuf, + path: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, exact: &mut bool, ) { - let path_uri = PathUri::from_abs_path(path); - match fs.get_metadata(&path_uri, sandbox).await { + match fs.get_metadata(path, sandbox).await { Ok(metadata) if metadata.is_file && !metadata.is_symlink => {} Ok(_) => *exact = false, Err(source) if source.kind() == io::ErrorKind::NotFound => {} @@ -618,37 +629,39 @@ async fn note_existing_path_delta_support( async fn write_file_with_missing_parent_retry( fs: &dyn ExecutorFileSystem, - path_abs: &AbsolutePathBuf, + path: &PathUri, contents: Vec, sandbox: Option<&FileSystemSandboxContext>, ) -> anyhow::Result<()> { - let path_uri = PathUri::from_abs_path(path_abs); - match fs.write_file(&path_uri, contents.clone(), sandbox).await { + match fs.write_file(path, contents.clone(), sandbox).await { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound => { - if let Some(parent_abs) = path_abs.parent() { - let parent_uri = PathUri::from_abs_path(&parent_abs); - fs.create_directory( - &parent_uri, - CreateDirectoryOptions { recursive: true }, - sandbox, - ) + if let Some(parent) = path.parent() { + fs.create_directory(&parent, CreateDirectoryOptions { recursive: true }, sandbox) + .await + .with_context(|| { + format!( + "Failed to create parent directories for {}", + path.inferred_native_path_string() + ) + })?; + } + fs.write_file(path, contents, sandbox) .await .with_context(|| { format!( - "Failed to create parent directories for {}", - path_abs.display() + "Failed to write file {}", + path.inferred_native_path_string() ) })?; - } - fs.write_file(&path_uri, contents, sandbox) - .await - .with_context(|| format!("Failed to write file {}", path_abs.display()))?; Ok(()) } - Err(err) => { - Err(err).with_context(|| format!("Failed to write file {}", path_abs.display())) - } + Err(err) => Err(err).with_context(|| { + format!( + "Failed to write file {}", + path.inferred_native_path_string() + ) + }), } } @@ -660,15 +673,17 @@ struct AppliedPatch { /// Return *only* the new file contents (joined into a single `String`) after /// applying the chunks to the file at `path`. async fn derive_new_contents_from_chunks( - path_abs: &AbsolutePathBuf, + path: &PathUri, chunks: &[UpdateFileChunk], fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { - let path_uri = PathUri::from_abs_path(path_abs); - let original_contents = fs.read_file_text(&path_uri, sandbox).await.map_err(|err| { + let original_contents = fs.read_file_text(path, sandbox).await.map_err(|err| { ApplyPatchError::IoError(IoError { - context: format!("Failed to read file to update {}", path_abs.display()), + context: format!( + "Failed to read file to update {}", + path.inferred_native_path_string() + ), source: err, }) })?; @@ -681,7 +696,8 @@ async fn derive_new_contents_from_chunks( original_lines.pop(); } - let replacements = compute_replacements(&original_lines, path_abs.as_path(), chunks)?; + let path_text = path.inferred_native_path_string(); + let replacements = compute_replacements(&original_lines, &path_text, chunks)?; let new_lines = apply_replacements(original_lines, &replacements); let mut new_lines = new_lines; if !new_lines.last().is_some_and(String::is_empty) { @@ -699,7 +715,7 @@ async fn derive_new_contents_from_chunks( /// `(start_index, old_len, new_lines)`. fn compute_replacements( original_lines: &[String], - path: &Path, + path: &str, chunks: &[UpdateFileChunk], ) -> std::result::Result)>, ApplyPatchError> { let mut replacements: Vec<(usize, usize, Vec)> = Vec::new(); @@ -718,9 +734,7 @@ fn compute_replacements( line_index = idx + 1; } else { return Err(ApplyPatchError::ComputeReplacements(format!( - "Failed to find context '{}' in {}", - ctx_line, - path.display() + "Failed to find context '{ctx_line}' in {path}" ))); } } @@ -776,7 +790,7 @@ fn compute_replacements( } else { return Err(ApplyPatchError::ComputeReplacements(format!( "Failed to find expected lines in {}:\n{}", - path.display(), + path, chunk.old_lines.join("\n"), ))); } @@ -824,16 +838,16 @@ pub struct ApplyPatchFileUpdate { } pub async fn unified_diff_from_chunks( - path_abs: &AbsolutePathBuf, + path: &PathUri, chunks: &[UpdateFileChunk], fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { - unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await + unified_diff_from_chunks_with_context(path, chunks, /*context*/ 1, fs, sandbox).await } pub async fn unified_diff_from_chunks_with_context( - path_abs: &AbsolutePathBuf, + path: &PathUri, chunks: &[UpdateFileChunk], context: usize, fs: &dyn ExecutorFileSystem, @@ -842,7 +856,7 @@ pub async fn unified_diff_from_chunks_with_context( let AppliedPatch { original_contents, new_contents, - } = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).await?; + } = derive_new_contents_from_chunks(path, chunks, fs, sandbox).await?; let text_diff = TextDiff::from_lines(&original_contents, &new_contents); let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); Ok(ApplyPatchFileUpdate { @@ -875,7 +889,6 @@ pub fn print_summary( mod tests { use super::*; use codex_exec_server::LOCAL_FS; - use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; @@ -900,7 +913,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -924,7 +937,7 @@ mod tests { #[tokio::test] async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() { let dir = tempdir().unwrap(); - let cwd = dir.path().abs(); + let cwd = PathUri::from_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"); @@ -1003,7 +1016,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1039,7 +1052,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1079,7 +1092,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1123,7 +1136,7 @@ mod tests { let mut stderr = Vec::new(); let failure = apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1183,7 +1196,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1241,7 +1254,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1285,7 +1298,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1328,7 +1341,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1378,9 +1391,9 @@ mod tests { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( - &path_abs, + &path_uri, update_file_chunks, LOCAL_FS.as_ref(), /*sandbox*/ None, @@ -1426,11 +1439,15 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); - let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) - .await - .unwrap(); + let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let diff = unified_diff_from_chunks( + &resolved_path, + chunks, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let expected_diff = r#"@@ -1,2 +1,2 @@ -foo +FOO @@ -1468,11 +1485,15 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); - let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) - .await - .unwrap(); + let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let diff = unified_diff_from_chunks( + &resolved_path, + chunks, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ bar -baz @@ -1508,9 +1529,9 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ @@ -1559,9 +1580,9 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); @@ -1589,7 +1610,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1627,7 +1648,7 @@ g let mut stderr = Vec::new(); let result = apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1646,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 = AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(); + let cwd = PathUri::from_path(dir.path()).expect("absolute test path"); for patch in [ wrap_patch("*** Add File: binary.dat\n+text"), @@ -1684,7 +1705,7 @@ g let mut stderr = Vec::new(); let delta = apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_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 5854f2616f8f..04e0053a3a3a 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -25,9 +25,10 @@ //! leading/trailing whitespace around patch markers. use crate::ApplyPatchArgs; use crate::streaming_parser::StreamingPatchParser; -use codex_utils_absolute_path::AbsolutePathBuf; #[cfg(test)] use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_path_uri::PathUri; +use codex_utils_path_uri::PathUriParseError; use std::path::Path; use std::path::PathBuf; @@ -81,12 +82,12 @@ pub enum Hunk { } impl Hunk { - pub fn resolve_path(&self, cwd: &AbsolutePathBuf) -> AbsolutePathBuf { + pub fn resolve_path(&self, cwd: &PathUri) -> Result { let path = match self { Hunk::UpdateFile { path, .. } => path, Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(), }; - AbsolutePathBuf::resolve_path_against_base(path, cwd) + cwd.join(&path.to_string_lossy()) } /// Returns the path affected by this hunk, using the move destination for rename hunks. @@ -479,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 = cwd_dir.path().to_path_buf().abs(); + let cwd = PathUri::from_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(); @@ -491,13 +492,13 @@ fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { path: PathBuf::from("relative-add.py"), contents: String::new(), }, - cwd.join("relative-add.py"), + cwd.join("relative-add.py").unwrap(), ), ( DeleteFile { path: PathBuf::from("relative-delete.py"), }, - cwd.join("relative-delete.py"), + cwd.join("relative-delete.py").unwrap(), ), ( UpdateFile { @@ -505,20 +506,20 @@ fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { move_path: None, chunks: Vec::new(), }, - cwd.join("relative-update.py"), + cwd.join("relative-update.py").unwrap(), ), ( AddFile { path: absolute_add.to_path_buf(), contents: String::new(), }, - absolute_add, + PathUri::from_abs_path(&absolute_add), ), ( DeleteFile { path: absolute_delete.to_path_buf(), }, - absolute_delete, + PathUri::from_abs_path(&absolute_delete), ), ( UpdateFile { @@ -526,10 +527,10 @@ fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { move_path: None, chunks: Vec::new(), }, - absolute_update, + PathUri::from_abs_path(&absolute_update), ), ] { - assert_eq!(hunk.resolve_path(&cwd), expected_path); + assert_eq!(hunk.resolve_path(&cwd), Ok(expected_path)); } } diff --git a/codex-rs/apply-patch/src/standalone_executable.rs b/codex-rs/apply-patch/src/standalone_executable.rs index 45ca0d0619c0..384b2ecee278 100644 --- a/codex-rs/apply-patch/src/standalone_executable.rs +++ b/codex-rs/apply-patch/src/standalone_executable.rs @@ -65,6 +65,8 @@ pub fn run_main() -> i32 { return 1; } }; + // TODO(anp): Discover the standalone executable cwd as PathUri directly. + let cwd = codex_utils_path_uri::PathUri::from_abs_path(&cwd); match runtime.block_on(crate::apply_patch( &patch_arg, &cwd, diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2102eaf875b0..1c28f81d8cbb 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -122,6 +122,7 @@ pub fn arg0_dispatch() -> Option { Ok(runtime) => runtime, Err(_) => std::process::exit(1), }; + let cwd = cwd.into(); match runtime.block_on(codex_apply_patch::apply_patch( &patch_arg, &cwd, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e8e4f5fb52b5..be444b6247c4 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 9731bc82b361..6cbeaf30b256 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 d469713891af..18ab32398603 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/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 5a024b3ff9c6..fa1a894cca65 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -8,7 +8,6 @@ use codex_app_server_protocol::AppInfo; use codex_connectors::ConnectorDirectoryCacheContext; use codex_connectors::ConnectorDirectoryCacheKey; use codex_connectors::DirectoryListResponse; -use codex_connectors::filter::filter_disallowed_connectors; use codex_connectors::merge::merge_connectors; use codex_connectors::merge::merge_plugin_connectors; use codex_core::config::Config; @@ -21,7 +20,6 @@ pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools pub use codex_core::connectors::with_app_enabled_state; use codex_login::AuthManager; use codex_login::CodexAuth; -use codex_login::default_client::originator; use codex_plugin::AppConnectorId; const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); @@ -82,7 +80,10 @@ pub async fn list_cached_all_connectors( let auth = connector_auth(config).await.ok()?; let cache_context = connector_directory_cache_context(config, &auth); let connectors = codex_connectors::cached_directory_connectors(&cache_context)?; - Some(merge_and_filter_plugin_connectors(connectors, plugin_apps)) + Some(merge_directory_and_plugin_connectors( + connectors, + plugin_apps, + )) } pub async fn list_all_connectors_with_options( @@ -109,7 +110,10 @@ pub async fn list_all_connectors_with_options( }, ) .await?; - Ok(merge_and_filter_plugin_connectors(connectors, plugin_apps)) + Ok(merge_directory_and_plugin_connectors( + connectors, + plugin_apps, + )) } fn connector_directory_cache_context( @@ -127,17 +131,16 @@ fn connector_directory_cache_context( ) } -fn merge_and_filter_plugin_connectors( +fn merge_directory_and_plugin_connectors( connectors: Vec, plugin_apps: &[AppConnectorId], ) -> Vec { - let connectors = merge_plugin_connectors( + merge_plugin_connectors( connectors, plugin_apps .iter() .map(|connector_id| connector_id.0.clone()), - ); - filter_disallowed_connectors(connectors, originator().value.as_str()) + ) } pub fn connectors_for_plugin_apps( @@ -150,11 +153,10 @@ pub fn connectors_for_plugin_apps( .iter() .map(|connector_id| connector_id.0.clone()), ); - let mut connectors_by_id = - filter_disallowed_connectors(connectors, originator().value.as_str()) - .into_iter() - .map(|connector| (connector.id.clone(), connector)) - .collect::>(); + let mut connectors_by_id = connectors + .into_iter() + .map(|connector| (connector.id.clone(), connector)) + .collect::>(); plugin_apps .iter() @@ -179,8 +181,7 @@ pub fn merge_connectors_with_accessible( } else { accessible_connectors }; - let merged = merge_connectors(connectors, accessible_connectors); - filter_disallowed_connectors(merged, originator().value.as_str()) + merge_connectors(connectors, accessible_connectors) } #[cfg(test)] @@ -269,13 +270,13 @@ mod tests { } #[test] - fn connectors_for_plugin_apps_filters_disallowed_plugin_apps() { - let connectors = connectors_for_plugin_apps( - Vec::new(), - &[AppConnectorId( - "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), - )], + fn connectors_for_plugin_apps_preserves_formerly_disallowed_plugin_apps() { + let connector_id = "asdk_app_6938a94a61d881918ef32cb999ff937c"; + let connectors = + connectors_for_plugin_apps(Vec::new(), &[AppConnectorId(connector_id.to_string())]); + assert_eq!( + connectors, + vec![merged_app(connector_id, /*is_accessible*/ false)] ); - assert_eq!(connectors, Vec::::new()); } } diff --git a/codex-rs/cli/src/account_list.rs b/codex-rs/cli/src/account_list.rs index fe372b906638..ea582d20b6a6 100644 --- a/codex-rs/cli/src/account_list.rs +++ b/codex-rs/cli/src/account_list.rs @@ -158,11 +158,13 @@ async fn credential_for_account_home( config: &Config, require_chatgpt: bool, ) -> Option { + let auth_route_config = config.auth_route_config(); match CodexAuth::from_auth_storage( codex_home, config.cli_auth_credentials_store_mode, Some(config.chatgpt_base_url.as_str()), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { diff --git a/codex-rs/cli/src/account_refresh.rs b/codex-rs/cli/src/account_refresh.rs index be4808df1a3b..e83cbddfd807 100644 --- a/codex-rs/cli/src/account_refresh.rs +++ b/codex-rs/cli/src/account_refresh.rs @@ -51,11 +51,13 @@ async fn refresh_single_account(config: &Config, account_id: &str) -> ! { } else { account_codex_home(&config.codex_home, Some(account_id)) }; + let auth_route_config = config.auth_route_config(); match CodexAuth::from_auth_storage( &account_home, config.cli_auth_credentials_store_mode, Some(config.chatgpt_base_url.as_str()), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -64,8 +66,10 @@ async fn refresh_single_account(config: &Config, account_id: &str) -> ! { account_home, /*enable_codex_api_key_env*/ false, config.cli_auth_credentials_store_mode, + /*forced_chatgpt_workspace_id*/ None, Some(config.chatgpt_base_url.clone()), config.auth_keyring_backend_kind(), + auth_route_config, ) .await; if access_token_expired(&auth) diff --git a/codex-rs/cli/src/account_usage.rs b/codex-rs/cli/src/account_usage.rs index 6f8e25d0ecb5..09f581848a7b 100644 --- a/codex-rs/cli/src/account_usage.rs +++ b/codex-rs/cli/src/account_usage.rs @@ -195,11 +195,13 @@ async fn credential_status( codex_home: &Path, require_chatgpt: bool, ) -> AccountCredentialStatus { + let auth_route_config = config.auth_route_config(); match CodexAuth::from_auth_storage( codex_home, config.cli_auth_credentials_store_mode, Some(config.chatgpt_base_url.as_str()), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -296,8 +298,10 @@ async fn render_account_usage(config: &Config, target: &AccountUsageTarget) -> S target.codex_home.clone(), /*enable_codex_api_key_env*/ false, config.cli_auth_credentials_store_mode, + /*forced_chatgpt_workspace_id*/ None, Some(config.chatgpt_base_url.clone()), config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await; let Some(auth) = manager.auth().await else { diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 55a22d015598..5e5bcb6c4e1d 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -282,9 +282,11 @@ async fn run_command_under_sandbox( network_sandbox_policy, sandbox_policy_cwd: sandbox_policy_cwd.as_path(), enforce_managed_network, + environment_id: None, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, - }); + }) + .map_err(|err| anyhow::anyhow!(err))?; spawn_debug_sandbox_child( PathBuf::from("/usr/bin/sandbox-exec"), args, diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 37a8d0bca266..c02966ce051a 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1399,8 +1399,8 @@ fn stored_auth_issues( codex_app_server_protocol::AuthMode::AgentIdentity => { if auth .agent_identity - .as_deref() - .is_none_or(|token| token.trim().is_empty()) + .as_ref() + .is_none_or(|agent_identity| !agent_identity.has_auth_material()) { issues.push("agent identity auth is missing an agent identity token"); } diff --git a/codex-rs/cli/src/doctor/thread_inventory.rs b/codex-rs/cli/src/doctor/thread_inventory.rs index 9a8eef6ee40a..c50e6debdc08 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/exec_server_telemetry.rs b/codex-rs/cli/src/exec_server_telemetry.rs new file mode 100644 index 000000000000..4d688a0159de --- /dev/null +++ b/codex-rs/cli/src/exec_server_telemetry.rs @@ -0,0 +1,41 @@ +use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; + +const DEFAULT_ANALYTICS_ENABLED: bool = false; +const DEFAULT_LOG_FILTER: &str = "error,opentelemetry_sdk=off,opentelemetry_otlp=off"; +const OTEL_SERVICE_NAME: &str = "codex-exec-server"; + +pub(crate) fn init( + config: Option<&codex_core::config::Config>, +) -> Result> { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter(stderr_env_filter()); + let otel = match config { + Some(config) => codex_core::otel_init::build_provider( + config, + env!("CARGO_PKG_VERSION"), + Some(OTEL_SERVICE_NAME), + DEFAULT_ANALYTICS_ENABLED, + ), + None => Ok(None), + }; + let provider = otel.as_ref().ok().and_then(Option::as_ref); + codex_core::otel_init::record_process_start(provider, OTEL_SERVICE_NAME); + + let otel_logger_layer = provider.and_then(|otel| otel.logger_layer()); + let otel_tracing_layer = provider.and_then(|otel| otel.tracing_layer()); + let _ = tracing_subscriber::registry() + .with(fmt_layer) + .with(otel_tracing_layer) + .with(otel_logger_layer) + .try_init(); + tracing::callsite::rebuild_interest_cache(); + otel +} + +fn stderr_env_filter() -> EnvFilter { + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(DEFAULT_LOG_FILTER)) + .unwrap_or_else(|_| EnvFilter::new("error")) +} diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index f1c5439734a5..ff165e38e7d9 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; @@ -122,11 +123,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 { @@ -139,11 +142,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; @@ -153,6 +158,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)?; @@ -198,12 +204,12 @@ pub async fn run_login_with_chatgpt( } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - match login_with_chatgpt( account_codex_home(&config.codex_home, account_id.as_deref()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await { @@ -294,6 +300,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, @@ -301,6 +308,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 { @@ -378,10 +386,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(); @@ -391,6 +401,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; @@ -424,10 +435,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; @@ -438,6 +451,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; @@ -481,12 +495,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 { @@ -535,6 +551,7 @@ pub async fn run_logout( all: bool, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_route_config = config.auth_route_config(); let result = if all { logout_all_accounts(&config).await @@ -544,6 +561,7 @@ pub async fn run_logout( &account_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await }; @@ -565,10 +583,12 @@ pub async fn run_logout( } async fn logout_all_accounts(config: &Config) -> std::io::Result { + let auth_route_config = config.auth_route_config(); let mut removed = logout_with_revoke( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await?; let accounts_dir = config.codex_home.join("accounts"); @@ -584,6 +604,7 @@ async fn logout_all_accounts(config: &Config) -> std::io::Result { &path, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await?; } @@ -647,6 +668,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 7a62d1c47593..f5b7eaf33ad0 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -56,6 +56,7 @@ mod app_cmd; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; mod doctor; +mod exec_server_telemetry; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; @@ -1852,6 +1853,9 @@ async fn run_exec_server_command( .environment_id .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; let config = load_exec_server_config(root_config_overrides, strict_config).await?; + let _otel = exec_server_telemetry::init(Some(&config)) + .inspect_err(|err| eprintln!("Could not create otel exporter: {err}")) + .ok(); let auth_provider = load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) .await?; @@ -1866,12 +1870,15 @@ async fn run_exec_server_command( codex_exec_server::run_remote_environment(remote_config, runtime_paths).await?; Ok(()) } else { - if strict_config { - // Local exec-server startup does not consume Config, but strict - // mode should still reject unknown fields before opening a listener. - let _validated_config = - load_exec_server_config(root_config_overrides, strict_config).await?; - } + let config_result = load_exec_server_config(root_config_overrides, strict_config).await; + let config = if strict_config { + Some(config_result?) + } else { + config_result.ok() + }; + let _otel = exec_server_telemetry::init(config.as_ref()) + .inspect_err(|err| eprintln!("Could not create otel exporter: {err}")) + .ok(); let listen_url = cmd .listen .as_deref() @@ -1891,9 +1898,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/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 1fc670c8717f..433714a00c43 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/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs index 0afe0d37b561..5e412a9de138 100644 --- a/codex-rs/cli/tests/exec_server.rs +++ b/codex-rs/cli/tests/exec_server.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::Result; +use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use tempfile::TempDir; @@ -33,3 +34,17 @@ foo = "bar" Ok(()) } + +#[test] +fn local_exec_server_ignores_invalid_config_without_strict_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("config.toml"), "not valid toml = [")?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["exec-server", "--listen", "stdio"]) + .assert() + .success() + .stderr(contains("not valid toml").not()); + + Ok(()) +} diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index 6bf58c839961..363cb21b6f13 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -25,6 +25,7 @@ tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tracing = { workspace = true } [dev-dependencies] +codex-agent-identity = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 2d76d29a6933..e4266c6195ba 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,13 +59,16 @@ 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(), enable_codex_api_key_env, credentials_store_mode, + /*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 d63fef4c541a..dfd4fe50ced8 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -17,6 +17,8 @@ use codex_config::CloudRequirementsFragment; use codex_config::CloudRequirementsTomlBundle; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; +use codex_login::auth::AgentIdentityAuth; +use codex_login::auth::AgentIdentityAuthRecord; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::VecDeque; @@ -48,8 +50,10 @@ async fn auth_manager_with_api_key() -> Arc { tmp.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -77,8 +81,10 @@ async fn auth_manager_with_plan_and_identity( tmp.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -88,6 +94,29 @@ async fn auth_manager_with_plan(plan_type: &str) -> Arc { auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")).await } +async fn auth_manager_with_agent_identity_business_plan() -> Arc { + let key_material = + codex_agent_identity::generate_agent_key_material().expect("generate agent key material"); + AuthManager::from_auth_for_testing(CodexAuth::AgentIdentity( + AgentIdentityAuth::from_record( + AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-123".to_string(), + agent_private_key: key_material.private_key_pkcs8_base64, + account_id: "account-12345".to_string(), + chatgpt_user_id: "user-12345".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"), + )) +} + fn chatgpt_auth_json( plan_type: &str, chatgpt_user_id: Option<&str>, @@ -408,6 +437,28 @@ async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() { } } +#[tokio::test] +async fn get_bundle_allows_agent_identity_business_plan() { + let bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_agent_identity_business_plan().await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + #[tokio::test] async fn get_bundle_skips_team_like_usage_based_plan() { let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); @@ -635,8 +686,10 @@ async fn get_bundle_recovers_after_unauthorized_reload() { auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -690,8 +743,10 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -753,8 +808,10 @@ async fn get_bundle_surfaces_auth_recovery_message() { auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -818,8 +875,10 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 f683c58a4b70..2a836f857931 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -49,8 +49,10 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option( + tasks: &mut JoinSet<()>, + host: Arc, + call_id: String, + text: String, + cancellation_token: CancellationToken, +) { + tasks.spawn(async move { + if let Err(err) = host.notify(call_id, text, cancellation_token).await { + warn!("failed to deliver code mode notification: {err}"); + } + }); +} + +pub(super) fn spawn_tool( + tasks: &mut JoinSet<()>, + host: Arc, + invocation: CellToolCall, + runtime_tx: std::sync::mpsc::Sender, + cancellation_token: CancellationToken, +) { + tasks.spawn(async move { + let id = invocation.id.clone(); + let command = match host.invoke_tool(invocation, cancellation_token).await { + Ok(result) => RuntimeCommand::ToolResponse { id, result }, + Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + }; + let _ = runtime_tx.send(command); + }); +} + +pub(super) async fn finish_callbacks( + cancellation_token: &CancellationToken, + notification_tasks: &mut JoinSet<()>, + tool_tasks: &mut JoinSet<()>, + completion: CallbackCompletion, +) { + if matches!(completion, CallbackCompletion::Cancel) { + cancellation_token.cancel(); + } + drain_tasks(notification_tasks, "notification").await; + cancellation_token.cancel(); + drain_tasks(tool_tasks, "tool").await; +} + +pub(super) fn log_task_result( + task_result: Option>, + description: &str, +) { + if let Some(Err(err)) = task_result + && !err.is_cancelled() + { + warn!("code mode {description} task failed: {err}"); + } +} + +async fn drain_tasks(tasks: &mut JoinSet<()>, description: &str) { + while let Some(result) = tasks.join_next().await { + log_task_result(Some(result), description); + } +} diff --git a/codex-rs/code-mode/src/cell_actor/conversions.rs b/codex-rs/code-mode/src/cell_actor/conversions.rs new file mode 100644 index 000000000000..7fb2a25c6349 --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/conversions.rs @@ -0,0 +1,60 @@ +use codex_code_mode_protocol::CodeModeToolKind; +use codex_code_mode_protocol::ExecuteRequest; +use codex_code_mode_protocol::FunctionCallOutputContentItem; +use codex_code_mode_protocol::ImageDetail; +use codex_code_mode_protocol::ToolDefinition; +use codex_protocol::ToolName; + +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ImageDetail as CellImageDetail; +use crate::session_runtime::OutputItem as CellOutputItem; +use crate::session_runtime::ToolKind as CellToolKind; + +pub(super) fn runtime_request(request: CellRequest) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: request.tool_call_id, + enabled_tools: request + .enabled_tools + .into_iter() + .map(|definition| ToolDefinition { + name: definition.name, + tool_name: ToolName { + name: definition.tool_name.name, + namespace: definition.tool_name.namespace, + }, + description: definition.description, + kind: match definition.kind { + CellToolKind::Function => CodeModeToolKind::Function, + CellToolKind::Freeform => CodeModeToolKind::Freeform, + }, + input_schema: None, + output_schema: None, + }) + .collect(), + source: request.source, + yield_time_ms: None, + max_output_tokens: None, + } +} + +pub(super) fn cell_tool_kind(kind: CodeModeToolKind) -> CellToolKind { + match kind { + CodeModeToolKind::Function => CellToolKind::Function, + CodeModeToolKind::Freeform => CellToolKind::Freeform, + } +} + +pub(super) fn output_item(item: FunctionCallOutputContentItem) -> CellOutputItem { + match item { + FunctionCallOutputContentItem::InputText { text } => CellOutputItem::Text { text }, + FunctionCallOutputContentItem::InputImage { image_url, detail } => CellOutputItem::Image { + image_url, + detail: detail.map(|detail| match detail { + ImageDetail::Auto => CellImageDetail::Auto, + ImageDetail::Low => CellImageDetail::Low, + ImageDetail::High => CellImageDetail::High, + ImageDetail::Original => CellImageDetail::Original, + }), + }, + } +} diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs new file mode 100644 index 000000000000..24eca22517e0 --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -0,0 +1,577 @@ +mod callbacks; +mod conversions; +mod types; + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; + +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +use self::callbacks::CallbackCompletion; +use self::callbacks::finish_callbacks; +use self::callbacks::log_task_result; +use self::callbacks::spawn_notification; +use self::callbacks::spawn_tool; +use self::conversions::cell_tool_kind; +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::CellEventFuture; +pub(crate) use self::types::CellHandle; +pub(crate) use self::types::CellHost; +pub(crate) use self::types::CellState; +pub(crate) use self::types::CellToolCall; +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; + +impl CellActor { + pub(crate) fn prepare( + request: CellRequest, + stored_values: HashMap, + host: Arc, + initial_observe_mode: ObserveMode, + cell_state: Arc, + ) -> Result< + ( + CellHandle, + CellEventFuture, + impl Future + Send + 'static, + ), + String, + > { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (command_tx, command_rx) = mpsc::unbounded_channel(); + let (initial_response_tx, initial_response_rx) = oneshot::channel(); + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + stored_values, + runtime_request(request), + event_tx, + PendingRuntimeMode::PauseUntilResumed, + )?; + let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); + let task = run_cell( + host, + CellContext { + runtime_tx, + runtime_control_tx, + runtime_terminate_handle, + cell_state, + }, + event_rx, + command_rx, + Observer { + mode: initial_observe_mode, + response_tx: initial_response_tx, + }, + ); + let initial_response = + Box::pin(async move { initial_response_rx.await.unwrap_or(Err(CellError::Closed)) }); + Ok((handle, initial_response, task)) + } +} + +struct CellContext { + runtime_tx: std::sync::mpsc::Sender, + runtime_control_tx: std::sync::mpsc::Sender, + runtime_terminate_handle: v8::IsolateHandle, + cell_state: Arc, +} + +struct Observer { + mode: ObserveMode, + response_tx: oneshot::Sender>, +} + +async fn run_cell( + host: Arc, + context: CellContext, + mut event_rx: mpsc::UnboundedReceiver, + command_rx: mpsc::UnboundedReceiver, + initial_observer: Observer, +) { + let CellContext { + runtime_tx, + runtime_control_tx, + runtime_terminate_handle, + 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 pending_frontier_ready = false; + let mut observer = Some(initial_observer); + 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; + _ = 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), + }, + ); + 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; + }; + 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() { + yield_timer.await; + } else { + std::future::pending::<()>().await; + } + } => { + yield_timer = None; + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, + ); + } + maybe_event = async { + if runtime_closed { + std::future::pending::>().await + } else { + event_rx.recv().await + } + }, if !yield_deadline_elapsed => { + let Some(event) = maybe_event else { + runtime_closed = true; + if termination || cancellation_token.is_cancelled() { + 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), + }, + ); + break; + } + 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, + /*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; + } + } + continue; + }; + match event { + RuntimeEvent::Started => { + yield_timer = observer.as_ref().and_then(observer_timer); + } + RuntimeEvent::Pending => { + runtime_paused = true; + if matches!( + observer.as_ref().map(|observer| observer.mode), + Some(ObserveMode::PendingFrontier) + ) { + yield_timer = None; + pending_frontier_ready = false; + match send_observer_event( + observer.take(), + 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:?}") + } + } + } else { + pending_tool_call_ids.clear(); + let _ = runtime_control_tx.send(RuntimeControlCommand::Continue); + runtime_paused = false; + } + } + RuntimeEvent::ContentItem(item) => content_items.push(output_item(item)), + RuntimeEvent::YieldRequested => { + let yield_observer = matches!( + observer.as_ref().map(|observer| observer.mode), + Some(ObserveMode::YieldAfter(_)) + ); + if yield_observer { + yield_timer = None; + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, + ); + } + } + RuntimeEvent::Notify { call_id, text } => { + spawn_notification( + &mut notification_tasks, + Arc::clone(&host), + call_id, + text, + callback_cancellation_token.child_token(), + ); + } + RuntimeEvent::ToolCall { id, name, kind, input } => { + pending_tool_call_ids.push(id.clone()); + spawn_tool( + &mut tool_tasks, + Arc::clone(&host), + CellToolCall { + id, + name: CellToolName { + name: name.name, + namespace: name.namespace, + }, + kind: cell_tool_kind(kind), + input, + }, + runtime_tx.clone(), + callback_cancellation_token.child_token(), + ); + } + RuntimeEvent::Result { stored_value_writes, error_text } => { + runtime_closed = true; + yield_timer = None; + if termination || cancellation_token.is_cancelled() { + 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), + }, + ); + break; + } + 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, + }; + 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), + ) { + 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; + } + } + } + } + } + task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => { + log_task_result(task_result, "notification"); + } + task_result = tool_tasks.join_next(), if !tool_tasks.is_empty() => { + log_task_result(task_result, "tool"); + } + } + } + // Reject requests that arrive while asynchronous terminal cleanup runs. + cell_state.tombstone(); + drop(command_rx.take()); + begin_termination( + &runtime_tx, + &runtime_control_tx, + &runtime_terminate_handle, + &cancellation_token, + ); + finish_callbacks( + &callback_cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ) + .await; + host.closed().await; +} + +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, +) -> 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 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 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, +) { + if let Some(event) = cell_state.finish_termination(event) + && let Some(observer_tx) = observer_tx + { + let _ = observer_tx.send(Ok(event)); + } +} + +fn observer_timer(observer: &Observer) -> Option>> { + match observer.mode { + ObserveMode::YieldAfter(duration) => Some(Box::pin(tokio::time::sleep(duration))), + ObserveMode::PendingFrontier => None, + } +} + +fn resume_for_observation( + mode: ObserveMode, + runtime_paused: &mut bool, + runtime_tx: &std::sync::mpsc::Sender, + runtime_control_tx: &std::sync::mpsc::Sender, +) { + if *runtime_paused { + let control = match mode { + ObserveMode::YieldAfter(_) => RuntimeControlCommand::Continue, + ObserveMode::PendingFrontier => RuntimeControlCommand::Resume, + }; + let _ = runtime_control_tx.send(control); + *runtime_paused = false; + } else if matches!(mode, ObserveMode::PendingFrontier) { + let _ = runtime_tx.send(RuntimeCommand::ObservePendingFrontier); + } +} + +fn begin_termination( + runtime_tx: &std::sync::mpsc::Sender, + runtime_control_tx: &std::sync::mpsc::Sender, + runtime_terminate_handle: &v8::IsolateHandle, + cancellation_token: &CancellationToken, +) { + cancellation_token.cancel(); + let _ = runtime_tx.send(RuntimeCommand::Terminate); + let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate); + let _ = runtime_terminate_handle.terminate_execution(); +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs new file mode 100644 index 000000000000..a4d93fc3e62f --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -0,0 +1,630 @@ +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; +use codex_code_mode_protocol::FunctionCallOutputContentItem; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; +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, + _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> { + 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) {} +} + +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) {} +} + +struct CellActorHarness { + event_tx: mpsc::UnboundedSender, + 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( + HashMap::new(), + ExecuteRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: "await new Promise(() => {});".to_string(), + yield_time_ms: None, + max_output_tokens: None, + }, + runtime_event_tx, + PendingRuntimeMode::PauseUntilResumed, + ) + .unwrap(); + 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( + host, + CellContext { + runtime_tx, + runtime_control_tx, + runtime_terminate_handle, + cell_state, + }, + event_rx, + command_rx, + Observer { + mode: initial_observe_mode, + response_tx: initial_event_tx, + }, + )); + + CellActorHarness { + event_tx, + 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)); + harness.event_tx.send(RuntimeEvent::Started).unwrap(); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "queued output".to_string(), + }, + )) + .unwrap(); + + assert_eq!( + harness.initial_event_rx.await.unwrap(), + Ok(CellEvent::Yielded { + content_items: Vec::new(), + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: vec![OutputItem::Text { + text: "queued output".to_string(), + }], + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn queued_termination_preempts_unobserved_runtime_completion() { + let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::from_secs(60))); + harness + .event_tx + .send(RuntimeEvent::Result { + stored_value_writes: HashMap::new(), + error_text: None, + }) + .unwrap(); + let termination = harness.handle.terminate(); + + let terminated = Ok(CellEvent::Terminated { + content_items: Vec::new(), + }); + assert_eq!(termination.await, terminated.clone()); + 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 new file mode 100644 index 000000000000..672820b93ecb --- /dev/null +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -0,0 +1,444 @@ +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +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 CellError { + Busy, + AlreadyTerminating, + Closed, +} + +pub(crate) struct CellToolCall { + pub(crate) id: String, + pub(crate) name: ToolName, + pub(crate) kind: ToolKind, + pub(crate) input: Option, +} + +/// Connects a cell actor to session-owned callbacks and stored values. +/// +/// 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, + invocation: CellToolCall, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn notify( + &self, + call_id: String, + text: String, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn commit_completion( + &self, + stored_value_writes: HashMap, + event: CellEvent, + pending_initial_yield_items: Option>, + cell_state: Arc, + ) -> impl Future + Send; + + fn closed(&self) -> impl Future + Send; +} + +#[derive(Clone)] +pub(crate) struct CellHandle { + command_tx: mpsc::UnboundedSender, + state: Arc, +} + +impl CellHandle { + pub(super) fn new( + command_tx: mpsc::UnboundedSender, + state: Arc, + ) -> Self { + 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 + .send(CellCommand::Observe { mode, response_tx }) + .is_err() + { + return closed_event(); + } + response_event(response_rx) + } + + pub(crate) fn terminate(&self) -> CellEventFuture { + 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, + } + } + + 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(); + 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, + } + } + } +} + +pub(super) enum CellCommand { + Observe { + mode: ObserveMode, + response_tx: oneshot::Sender>, + }, +} + +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 269925827161..cbe8bfe6df44 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -1,5 +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 36ffd926cc7c..42af5c0b9577 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -25,17 +25,20 @@ pub(crate) enum RuntimeCommand { ToolResponse { id: String, result: JsonValue }, ToolError { id: String, error_text: String }, TimeoutFired { id: u64 }, + ObservePendingFrontier, Terminate, } #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum PendingRuntimeMode { + #[cfg(test)] Continue, PauseUntilResumed, } #[derive(Debug)] pub(crate) enum RuntimeControlCommand { + Continue, Resume, Terminate, } @@ -245,6 +248,7 @@ fn run_runtime( return; } } + RuntimeCommand::ObservePendingFrontier => {} } scope.perform_microtask_checkpoint(); @@ -283,8 +287,10 @@ fn next_runtime_command( let _ = event_tx.send(RuntimeEvent::Pending); match pending_mode { + #[cfg(test)] PendingRuntimeMode::Continue => return command_rx.recv().ok(), PendingRuntimeMode::PauseUntilResumed => match control_rx.recv().ok()? { + RuntimeControlCommand::Continue => return command_rx.recv().ok(), RuntimeControlCommand::Resume => continue, RuntimeControlCommand::Terminate => return Some(RuntimeCommand::Terminate), }, @@ -414,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 6eb084ea5a3e..bb7aa842e1f7 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; @@ -12,10 +8,12 @@ use codex_code_mode_protocol::CodeModeSessionDelegate; use codex_code_mode_protocol::CodeModeSessionProvider; use codex_code_mode_protocol::CodeModeSessionProviderFuture; use codex_code_mode_protocol::CodeModeSessionResultFuture; +use codex_code_mode_protocol::CodeModeToolKind; use codex_code_mode_protocol::DEFAULT_EXEC_YIELD_TIME_MS; use codex_code_mode_protocol::ExecuteRequest; use codex_code_mode_protocol::ExecuteToPendingOutcome; use codex_code_mode_protocol::FunctionCallOutputContentItem; +use codex_code_mode_protocol::ImageDetail; use codex_code_mode_protocol::NotificationFuture; use codex_code_mode_protocol::RuntimeResponse; use codex_code_mode_protocol::StartedCell; @@ -25,18 +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::mpsc; use tokio::sync::oneshot; -use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; -use tracing::warn; -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 as runtime; +use crate::session_runtime::SessionRuntime; pub struct NoopCodeModeSessionDelegate; @@ -81,23 +72,8 @@ impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { } } -#[derive(Clone)] -struct CellHandle { - control_tx: mpsc::UnboundedSender, - runtime_tx: std::sync::mpsc::Sender, - cancellation_token: CancellationToken, -} - -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 { @@ -107,157 +83,98 @@ 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 { - if self.inner.shutting_down.load(Ordering::Acquire) { - return Err("code mode session is shutting down".to_string()); - } - let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); + let yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + ) + .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(); - let cell_id = self.allocate_cell_id(); - self.start_cell( - cell_id.clone(), - request, - CellResponseSender::Runtime(response_tx), - Some(initial_yield_time_ms), - PendingRuntimeMode::Continue, - ) - .await?; - - Ok(StartedCell::new(cell_id, response_rx)) + tokio::spawn(async move { + let response = started + .initial_event() + .await + .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)) } pub async fn execute_to_pending( &self, request: ExecuteRequest, ) -> Result { - let (response_tx, response_rx) = oneshot::channel(); - let cell_id = self.allocate_cell_id(); - self.start_cell( - cell_id, - request, - CellResponseSender::ExecuteToPending(response_tx), - /*initial_yield_time_ms*/ None, - PendingRuntimeMode::PauseUntilResumed, - ) - .await?; - - response_rx + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::PendingFrontier, + ) + .await + .map_err(|error| error.to_string())?; + let cell_id = protocol_cell_id(&started.cell_id); + let event = started + .initial_event() .await - .map_err(|_| "exec runtime ended unexpectedly".to_string()) + .map_err(|error| error.to_string())?; + pending_outcome(&cell_id, event) } - async fn start_cell( - &self, - cell_id: CellId, - request: ExecuteRequest, - initial_response_tx: CellResponseSender, - initial_yield_time_ms: Option, - pending_mode: PendingRuntimeMode, - ) -> Result<(), String> { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (control_tx, control_rx) = mpsc::unbounded_channel(); - let stored_values = self.inner.stored_values.lock().await.clone(); - let cancellation_token = CancellationToken::new(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = { - 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 (runtime_tx, runtime_control_tx, runtime_terminate_handle) = - spawn_runtime(stored_values, request, event_tx, pending_mode)?; - - cells.insert( - cell_id.clone(), - CellHandle { - control_tx, - runtime_tx: runtime_tx.clone(), - cancellation_token: cancellation_token.clone(), - }, - ); - (runtime_tx, runtime_control_tx, runtime_terminate_handle) - }; - - tokio::spawn(run_cell_control( - Arc::clone(&self.inner), - CellControlContext { - cell_id, - runtime_tx, - runtime_control_tx, - pending_mode, - runtime_terminate_handle, - cancellation_token, - }, - event_rx, - control_rx, - initial_response_tx, - initial_yield_time_ms, - )); - - Ok(()) + pub async fn wait(&self, request: WaitRequest) -> Result { + self.begin_wait(request).await.await } - pub async fn wait(&self, request: WaitRequest) -> Result { + async fn begin_wait( + &self, + request: WaitRequest, + ) -> CodeModeSessionResultFuture<'static, WaitOutcome> { let WaitRequest { cell_id, yield_time_ms, } = request; - 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))); - }; - let (response_tx, response_rx) = oneshot::channel(); - let control_message = CellControlCommand::Poll { - yield_time_ms, - response_tx, - }; - if handle.control_tx.send(control_message).is_err() { - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); - } - match response_rx.await { - Ok(response) => Ok(WaitOutcome::LiveCell(response)), - Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), + 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))); - }; - let (response_tx, response_rx) = oneshot::channel(); - if handle - .control_tx - .send(CellControlCommand::Terminate { response_tx }) - .is_err() - { - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); - } - match response_rx.await { - Ok(response) => Ok(WaitOutcome::LiveCell(response)), - Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), + 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()), } } @@ -266,52 +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, - ))); - }; - let (response_tx, response_rx) = oneshot::channel(); - if handle - .control_tx - .send(CellControlCommand::PollToPending { response_tx }) - .is_err() + match self + .runtime + .observe( + &runtime_cell_id(&cell_id), + runtime::ObserveMode::PendingFrontier, + ) + .await { - return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))); - } - match response_rx.await { - Ok(response) => Ok(WaitToPendingOutcome::LiveCell(response)), - Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))), + Ok(event) => Ok(WaitToPendingOutcome::LiveCell(pending_outcome( + &cell_id, event, + )?)), + 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.cancellation_token.cancel(); - let (response_tx, _response_rx) = oneshot::channel(); - let _ = handle - .control_tx - .send(CellControlCommand::Terminate { response_tx }); - let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); - } - while !self.inner.cells.lock().await.is_empty() { - tokio::task::yield_now().await; - } - Ok(()) + .map_err(|error| error.to_string()) } } @@ -321,25 +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.cancellation_token.cancel(); - let (response_tx, _response_rx) = oneshot::channel(); - let _ = handle - .control_tx - .send(CellControlCommand::Terminate { response_tx }); - let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); - } - } - } -} - impl CodeModeSession for CodeModeService { fn is_alive(&self) -> bool { - !self.inner.shutting_down.load(Ordering::Acquire) + self.runtime.is_alive() } fn execute<'a>( @@ -362,1460 +240,168 @@ impl CodeModeSession for CodeModeService { } } -enum CellControlCommand { - Poll { - yield_time_ms: u64, - response_tx: oneshot::Sender, - }, - PollToPending { - response_tx: oneshot::Sender, - }, - Terminate { - response_tx: oneshot::Sender, - }, +struct ProtocolDelegate { + delegate: Arc, } -enum CellResponseSender { - Runtime(oneshot::Sender), - ExecuteToPending(oneshot::Sender), -} +impl runtime::SessionRuntimeDelegate for ProtocolDelegate { + async fn invoke_tool( + &self, + invocation: runtime::NestedToolCall, + cancellation_token: CancellationToken, + ) -> Result { + self.delegate + .invoke_tool( + CodeModeNestedToolCall { + cell_id: protocol_cell_id(&invocation.cell_id), + runtime_tool_call_id: invocation.runtime_tool_call_id, + tool_name: codex_protocol::ToolName { + name: invocation.tool_name.name, + namespace: invocation.tool_name.namespace, + }, + tool_kind: match invocation.tool_kind { + runtime::ToolKind::Function => CodeModeToolKind::Function, + runtime::ToolKind::Freeform => CodeModeToolKind::Freeform, + }, + input: invocation.input, + }, + cancellation_token, + ) + .await + } -struct PendingResult { - content_items: Vec, - error_text: Option, -} + async fn notify( + &self, + call_id: String, + cell_id: runtime::CellId, + text: String, + cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.delegate + .notify( + call_id, + protocol_cell_id(&cell_id), + text, + cancellation_token, + ) + .await + } -struct CellControlContext { - cell_id: CellId, - runtime_tx: std::sync::mpsc::Sender, - runtime_control_tx: std::sync::mpsc::Sender, - pending_mode: PendingRuntimeMode, - runtime_terminate_handle: v8::IsolateHandle, - cancellation_token: CancellationToken, + fn cell_closed(&self, cell_id: &runtime::CellId) { + self.delegate.cell_closed(&protocol_cell_id(cell_id)); + } } -fn missing_cell_response(cell_id: CellId) -> RuntimeResponse { - RuntimeResponse::Result { - error_text: Some(format!("exec cell {cell_id} not found")), - cell_id, - content_items: Vec::new(), +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| runtime::ToolDefinition { + name: definition.name, + tool_name: runtime::ToolName { + name: definition.tool_name.name, + namespace: definition.tool_name.namespace, + }, + description: definition.description, + kind: match definition.kind { + CodeModeToolKind::Function => runtime::ToolKind::Function, + CodeModeToolKind::Freeform => runtime::ToolKind::Freeform, + }, + }) + .collect(), + source: request.source, } } -fn pending_result_response(cell_id: &CellId, result: PendingResult) -> RuntimeResponse { - RuntimeResponse::Result { - cell_id: cell_id.clone(), - content_items: result.content_items, - error_text: result.error_text, - } +fn runtime_cell_id(cell_id: &CellId) -> runtime::CellId { + runtime::CellId::new(cell_id.as_str()) } -fn send_terminal_response(response_tx: CellResponseSender, response: RuntimeResponse) { - match response_tx { - CellResponseSender::Runtime(response_tx) => { - let _ = response_tx.send(response); - } - CellResponseSender::ExecuteToPending(response_tx) => { - let _ = response_tx.send(ExecuteToPendingOutcome::Completed(response)); - } - } +fn protocol_cell_id(cell_id: &runtime::CellId) -> CellId { + CellId::new(cell_id.as_str().to_string()) } -fn send_or_buffer_result( +fn pending_outcome( cell_id: &CellId, - result: PendingResult, - response_tx: &mut Option, - pending_result: &mut Option, -) -> bool { - if let Some(response_tx) = response_tx.take() { - let response = pending_result_response(cell_id, result); - send_terminal_response(response_tx, response); - return true; + event: runtime::CellEvent, +) -> Result { + match event { + runtime::CellEvent::Pending { + content_items, + pending_tool_call_ids, + } => Ok(ExecuteToPendingOutcome::Pending { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + pending_tool_call_ids, + }), + event => Ok(ExecuteToPendingOutcome::Completed(runtime_response( + cell_id, event, + )?)), } - - *pending_result = Some(result); - false } -fn send_yield_response( +fn runtime_response( cell_id: &CellId, - content_items: &mut Vec, - response_tx: &mut Option, -) { - let Some(current_response_tx) = response_tx.take() else { - return; - }; - match current_response_tx { - CellResponseSender::Runtime(response_tx) => { - let _ = response_tx.send(RuntimeResponse::Yielded { - cell_id: cell_id.clone(), - content_items: std::mem::take(content_items), - }); - } - CellResponseSender::ExecuteToPending(execute_to_pending_tx) => { - *response_tx = Some(CellResponseSender::ExecuteToPending(execute_to_pending_tx)); + event: runtime::CellEvent, +) -> Result { + match event { + runtime::CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + }), + runtime::CellEvent::Completed { + content_items, + error_text, + } => Ok(RuntimeResponse::Result { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + error_text, + }), + runtime::CellEvent::Terminated { content_items } => Ok(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: content_items.into_iter().map(output_item).collect(), + }), + runtime::CellEvent::Pending { .. } => { + Err("cell returned a pending frontier unexpectedly".to_string()) } } } -async fn run_cell_control( - inner: Arc, - context: CellControlContext, - mut event_rx: mpsc::UnboundedReceiver, - mut control_rx: mpsc::UnboundedReceiver, - initial_response_tx: CellResponseSender, - initial_yield_time_ms: Option, -) { - let CellControlContext { - cell_id, - runtime_tx, - runtime_control_tx, - pending_mode, - runtime_terminate_handle, - cancellation_token, - } = context; - let mut content_items = Vec::new(); - let mut pending_tool_call_ids = Vec::new(); - let mut pending_result: Option = None; - let mut response_tx = Some(initial_response_tx); - let mut termination_requested = false; - let mut runtime_closed = false; - let mut yield_timer: Option>> = None; - let mut notification_tasks = JoinSet::new(); - - loop { - tokio::select! { - maybe_event = async { - if runtime_closed { - std::future::pending::>().await - } else { - event_rx.recv().await - } - } => { - let Some(event) = maybe_event else { - runtime_closed = true; - if termination_requested { - if let Some(response_tx) = response_tx.take() { - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_terminal_response(response_tx, response); - } - break; - } - if pending_result.is_none() { - let result = PendingResult { - content_items: std::mem::take(&mut content_items), - error_text: Some("exec runtime ended unexpectedly".to_string()), - }; - if send_or_buffer_result( - &cell_id, - result, - &mut response_tx, - &mut pending_result, - ) { - break; - } - } - continue; - }; - match event { - RuntimeEvent::Started => { - yield_timer = initial_yield_time_ms.map(|initial_yield_time_ms| { - Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms))) - }); - } - RuntimeEvent::Pending => { - if let Some(current_response_tx) = response_tx.take() { - match current_response_tx { - CellResponseSender::Runtime(runtime_response_tx) => { - response_tx = - Some(CellResponseSender::Runtime(runtime_response_tx)); - } - CellResponseSender::ExecuteToPending(response_tx) => { - let _ = response_tx.send(ExecuteToPendingOutcome::Pending { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - pending_tool_call_ids: std::mem::take( - &mut pending_tool_call_ids, - ), - }); - } - } - } - } - RuntimeEvent::ContentItem(item) => { - content_items.push(item); - } - RuntimeEvent::YieldRequested => { - yield_timer = None; - send_yield_response(&cell_id, &mut content_items, &mut response_tx); - } - RuntimeEvent::Notify { call_id, text } => { - let delegate = Arc::clone(&inner.delegate); - let cell_id = cell_id.clone(); - let cancellation_token = cancellation_token.child_token(); - notification_tasks.spawn(async move { - tokio::select! { - result = delegate.notify( - call_id, - cell_id.clone(), - text, - cancellation_token.clone(), - ) => { - if let Err(err) = result { - warn!( - "failed to deliver code mode notification for cell {cell_id}: {err}" - ); - } - } - _ = cancellation_token.cancelled() => {} - } - }); - } - RuntimeEvent::ToolCall { - id, - name, - kind, - input, - } => { - if pending_mode == PendingRuntimeMode::PauseUntilResumed { - pending_tool_call_ids.push(id.clone()); - } - let tool_call = CodeModeNestedToolCall { - cell_id: cell_id.clone(), - runtime_tool_call_id: id.clone(), - tool_name: name, - tool_kind: kind, - input, - }; - let delegate = Arc::clone(&inner.delegate); - let runtime_tx = runtime_tx.clone(); - let cancellation_token = cancellation_token.child_token(); - tokio::spawn(async move { - let response = tokio::select! { - response = delegate.invoke_tool(tool_call, cancellation_token.clone()) => response, - _ = cancellation_token.cancelled() => return, - }; - let command = match response { - Ok(result) => RuntimeCommand::ToolResponse { id, result }, - Err(error_text) => RuntimeCommand::ToolError { id, error_text }, - }; - let _ = runtime_tx.send(command); - }); - } - RuntimeEvent::Result { - stored_value_writes, - error_text, - } => { - yield_timer = None; - if termination_requested { - if let Some(response_tx) = response_tx.take() { - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_terminal_response(response_tx, response); - } - break; - } - drain_notification_tasks(&mut notification_tasks).await; - inner - .stored_values - .lock() - .await - .extend(stored_value_writes); - let result = PendingResult { - content_items: std::mem::take(&mut content_items), - error_text, - }; - if send_or_buffer_result( - &cell_id, - result, - &mut response_tx, - &mut pending_result, - ) { - break; - } - } - } - } - task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => { - if let Some(Err(err)) = task_result - && !err.is_cancelled() - { - warn!("code mode notification task failed: {err}"); - } - } - maybe_command = control_rx.recv() => { - let Some(command) = maybe_command else { - break; - }; - match command { - CellControlCommand::Poll { - yield_time_ms, - response_tx: next_response_tx, - } => { - if let Some(result) = pending_result.take() { - let _ = next_response_tx.send(pending_result_response(&cell_id, result)); - break; - } - response_tx = Some(CellResponseSender::Runtime(next_response_tx)); - yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); - resume_paused_runtime(&runtime_control_tx, pending_mode); - } - CellControlCommand::PollToPending { - response_tx: next_response_tx, - } => { - if let Some(result) = pending_result.take() { - let response = pending_result_response(&cell_id, result); - let _ = next_response_tx - .send(ExecuteToPendingOutcome::Completed(response)); - break; - } - response_tx = - Some(CellResponseSender::ExecuteToPending(next_response_tx)); - yield_timer = None; - resume_paused_runtime(&runtime_control_tx, pending_mode); - } - CellControlCommand::Terminate { response_tx: next_response_tx } => { - if let Some(result) = pending_result.take() { - let _ = next_response_tx.send(pending_result_response(&cell_id, result)); - break; - } - - response_tx = Some(CellResponseSender::Runtime(next_response_tx)); - termination_requested = true; - cancellation_token.cancel(); - yield_timer = None; - let _ = runtime_tx.send(RuntimeCommand::Terminate); - terminate_paused_runtime(&runtime_control_tx, pending_mode); - let _ = runtime_terminate_handle.terminate_execution(); - if runtime_closed { - if let Some(response_tx) = response_tx.take() { - let response = RuntimeResponse::Terminated { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }; - send_terminal_response(response_tx, response); - } - break; - } else { - continue; - } - } - } - } - _ = async { - if let Some(yield_timer) = yield_timer.as_mut() { - yield_timer.await; - } else { - std::future::pending::<()>().await; - } - } => { - yield_timer = None; - send_yield_response(&cell_id, &mut content_items, &mut response_tx); +fn output_item(item: runtime::OutputItem) -> FunctionCallOutputContentItem { + match item { + 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, + }), } } } - - let _ = runtime_tx.send(RuntimeCommand::Terminate); - cancellation_token.cancel(); - drain_notification_tasks(&mut notification_tasks).await; - terminate_paused_runtime(&runtime_control_tx, pending_mode); - inner.cells.lock().await.remove(&cell_id); - inner.delegate.cell_closed(&cell_id); -} - -async fn drain_notification_tasks(notification_tasks: &mut JoinSet<()>) { - while let Some(result) = notification_tasks.join_next().await { - if let Err(err) = result - && !err.is_cancelled() - { - warn!("code mode notification task failed: {err}"); - } - } } -fn resume_paused_runtime( - runtime_control_tx: &std::sync::mpsc::Sender, - pending_mode: PendingRuntimeMode, -) { - if pending_mode == PendingRuntimeMode::PauseUntilResumed { - let _ = runtime_control_tx.send(RuntimeControlCommand::Resume); +fn missing_cell_response(cell_id: CellId) -> RuntimeResponse { + RuntimeResponse::Result { + error_text: Some(format!("exec cell {cell_id} not found")), + cell_id, + content_items: Vec::new(), } } -fn terminate_paused_runtime( - runtime_control_tx: &std::sync::mpsc::Sender, - pending_mode: PendingRuntimeMode, -) { - if pending_mode == PendingRuntimeMode::PauseUntilResumed { - let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate); - } +fn missing_wait(cell_id: CellId) -> CodeModeSessionResultFuture<'static, WaitOutcome> { + Box::pin(async move { Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) }) } #[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::Arc; - use std::sync::atomic::AtomicU64; - use std::sync::atomic::Ordering; - use std::time::Duration; - - use codex_protocol::ToolName; - use pretty_assertions::assert_eq; - use tokio::sync::Mutex; - use tokio::sync::mpsc; - use tokio::sync::oneshot; - - use super::CellControlCommand; - use super::CellControlContext; - use super::CellId; - use super::CellResponseSender; - use super::CodeModeService; - use super::Inner; - use super::NoopCodeModeSessionDelegate; - use super::PendingRuntimeMode; - use super::RuntimeCommand; - use super::RuntimeResponse; - use super::WaitOutcome; - use super::WaitRequest; - use super::WaitToPendingOutcome; - use super::WaitToPendingRequest; - use super::run_cell_control; - use crate::CodeModeToolKind; - use crate::ExecuteRequest; - use crate::ExecuteToPendingOutcome; - use crate::FunctionCallOutputContentItem; - use crate::ToolDefinition; - use crate::runtime::RuntimeEvent; - use crate::runtime::spawn_runtime; - - fn execute_request(source: &str) -> ExecuteRequest { - ExecuteRequest { - tool_call_id: "call_1".to_string(), - enabled_tools: Vec::new(), - source: source.to_string(), - yield_time_ms: Some(1), - max_output_tokens: None, - } - } - - fn cell_id(value: &str) -> CellId { - CellId::new(value.to_string()) - } - - async fn execute(service: &CodeModeService, request: ExecuteRequest) -> RuntimeResponse { - service - .execute(request) - .await - .unwrap() - .initial_response() - .await - .unwrap() - } - - fn test_inner() -> Arc { - Arc::new(Inner { - stored_values: Mutex::new(HashMap::new()), - cells: Mutex::new(HashMap::new()), - delegate: Arc::new(NoopCodeModeSessionDelegate), - shutting_down: std::sync::atomic::AtomicBool::new(false), - next_cell_id: AtomicU64::new(1), - }) - } - - #[tokio::test] - async fn synchronous_exit_returns_successfully() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#"text("before"); exit(); text("after");"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "before".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn stored_values_are_shared_between_cells_but_not_sessions() { - let first_session = CodeModeService::new(); - let second_session = CodeModeService::new(); - - let write_response = execute( - &first_session, - ExecuteRequest { - source: r#"store("key", "visible");"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - let same_session = execute( - &first_session, - ExecuteRequest { - source: r#"text(String(load("key")));"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - let other_session = execute( - &second_session, - ExecuteRequest { - source: r#"text(String(load("key")));"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - write_response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: None, - } - ); - assert_eq!( - same_session, - RuntimeResponse::Result { - cell_id: cell_id("2"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "visible".to_string(), - }], - error_text: None, - } - ); - assert_eq!( - other_session, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "undefined".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn shutdown_interrupts_cpu_bound_cells() { - let service = CodeModeService::new(); - - let cell = service - .execute(ExecuteRequest { - source: "while (true) {}".to_string(), - ..execute_request("") - }) - .await - .unwrap(); - assert_eq!( - cell.initial_response().await.unwrap(), - RuntimeResponse::Yielded { - cell_id: cell_id("1"), - content_items: Vec::new(), - } - ); - - tokio::time::timeout(Duration::from_secs(1), service.shutdown()) - .await - .unwrap() - .unwrap(); - } - - #[tokio::test] - async fn start_cell_rejects_new_cell_after_shutdown_begins() { - let service = CodeModeService::new(); - service.inner.shutting_down.store(true, Ordering::Release); - let (response_tx, _response_rx) = oneshot::channel(); +#[path = "service_tests.rs"] +mod tests; - let error = service - .start_cell( - cell_id("late-cell"), - execute_request(""), - CellResponseSender::Runtime(response_tx), - Some(/*initial_yield_time_ms*/ 1), - PendingRuntimeMode::Continue, - ) - .await - .unwrap_err(); - - assert_eq!(error, "code mode session is shutting down".to_string()); - assert!(service.inner.cells.lock().await.is_empty()); - } - - #[tokio::test] - async fn execute_to_pending_returns_completed_for_synchronous_results() { - let service = CodeModeService::new(); - - let response = service - .execute_to_pending(ExecuteRequest { - source: r#"text("done");"#.to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - response, - ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "done".to_string(), - }], - error_text: None, - }) - ); - } - - #[tokio::test] - async fn execute_to_pending_returns_once_the_runtime_is_quiescent() { - let service = CodeModeService::new(); - - let response = tokio::time::timeout( - Duration::from_secs(1), - service.execute_to_pending(ExecuteRequest { - source: r#"text("before"); await new Promise(() => {});"#.to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "before".to_string(), - }], - pending_tool_call_ids: Vec::new(), - } - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() { - let service = CodeModeService::new(); - - let response = service - .execute_to_pending(ExecuteRequest { - enabled_tools: vec![ToolDefinition { - name: "echo".to_string(), - tool_name: ToolName::plain("echo"), - description: String::new(), - kind: CodeModeToolKind::Function, - input_schema: None, - output_schema: None, - }], - source: r#" -await Promise.all([ - tools.echo({ value: "first" }), - tools.echo({ value: "second" }), -]); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], - } - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() { - let service = CodeModeService::new(); - - let initial_response = service - .execute_to_pending(ExecuteRequest { - enabled_tools: vec![ToolDefinition { - name: "echo".to_string(), - tool_name: ToolName::plain("echo"), - description: String::new(), - kind: CodeModeToolKind::Function, - input_schema: None, - output_schema: None, - }], - source: r#" -setTimeout(() => { - tools.echo({ value: "delayed" }); -}, 1000); -await Promise.all([ - tools.echo({ value: "second" }), - tools.echo({ value: "third" }), -]); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - initial_response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], - } - ); - - let runtime_tx = service - .inner - .cells - .lock() - .await - .get(&cell_id("1")) - .unwrap() - .runtime_tx - .clone(); - runtime_tx - .send(RuntimeCommand::TimeoutFired { id: 1 }) - .unwrap(); - - let resumed_response = tokio::time::timeout( - Duration::from_secs(1), - service.wait_to_pending(WaitToPendingRequest { - cell_id: cell_id("1"), - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - resumed_response, - WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: vec!["tool-3".to_string()], - }) - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() { - let service = CodeModeService::new(); - - let initial_response = service - .execute_to_pending(ExecuteRequest { - source: r#" -await new Promise((resolve) => setTimeout(resolve, 60_000)); -text("after"); -await new Promise(() => {}); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - initial_response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: Vec::new(), - } - ); - - let runtime_tx = service - .inner - .cells - .lock() - .await - .get(&cell_id("1")) - .unwrap() - .runtime_tx - .clone(); - runtime_tx - .send(RuntimeCommand::TimeoutFired { id: 1 }) - .unwrap(); - - let resumed_response = tokio::time::timeout( - Duration::from_secs(1), - service.wait_to_pending(WaitToPendingRequest { - cell_id: cell_id("1"), - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - resumed_response, - WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "after".to_string(), - }], - pending_tool_call_ids: Vec::new(), - }) - ); - - let termination = service.terminate(cell_id("1")).await.unwrap(); - - assert_eq!( - termination, - WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: cell_id("1"), - content_items: Vec::new(), - }) - ); - } - - #[tokio::test] - async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() { - let service = CodeModeService::new(); - - let initial_response = service - .execute_to_pending(ExecuteRequest { - source: r#" -await new Promise((resolve) => setTimeout(resolve, 60_000)); -text("done"); -"# - .to_string(), - yield_time_ms: Some(60_000), - ..execute_request("") - }) - .await - .unwrap(); - - assert_eq!( - initial_response, - ExecuteToPendingOutcome::Pending { - cell_id: cell_id("1"), - content_items: Vec::new(), - pending_tool_call_ids: Vec::new(), - } - ); - - let runtime_tx = service - .inner - .cells - .lock() - .await - .get(&cell_id("1")) - .unwrap() - .runtime_tx - .clone(); - runtime_tx - .send(RuntimeCommand::TimeoutFired { id: 1 }) - .unwrap(); - - let resumed_response = tokio::time::timeout( - Duration::from_secs(1), - service.wait_to_pending(WaitToPendingRequest { - cell_id: cell_id("1"), - }), - ) - .await - .unwrap() - .unwrap(); - - assert_eq!( - resumed_response, - WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "done".to_string(), - }], - error_text: None, - } - )) - ); - } - - #[tokio::test] - async fn v8_console_is_not_exposed_on_global_this() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "false".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn date_locale_string_formats_with_icu_data() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -const value = new Date("2025-01-02T03:04:05Z") - .toLocaleString("fr-FR", { - weekday: "long", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - timeZone: "UTC", - }); -text(value); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn intl_date_time_format_formats_with_icu_data() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -const formatter = new Intl.DateTimeFormat("fr-FR", { - weekday: "long", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - timeZone: "UTC", -}); -text(formatter.format(new Date("2025-01-02T03:04:05Z"))); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn output_helpers_return_undefined() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -const returnsUndefined = [ - text("first"), - image("data:image/png;base64,AAA"), - notify("ping"), -].map((value) => value === undefined); -text(JSON.stringify(returnsUndefined)); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![ - FunctionCallOutputContentItem::InputText { - text: "first".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: "[true,true,true]".to_string(), - }, - ], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - mimeType: "image/png", - _meta: { "codex/imageDetail": "original" }, -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: Some(crate::ImageDetail::Original), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn generated_image_helper_appends_image_and_output_hint() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -generatedImage({ - image_url: "data:image/png;base64,AAA", - output_hint: "generated image save hint", -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![ - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: "generated image save hint".to_string(), - }, - ], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_second_arg_overrides_explicit_object_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image( - { - image_url: "data:image/png;base64,AAA", - detail: "high", - }, - "original", -); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::ImageDetail::Original), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_second_arg_overrides_raw_mcp_image_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image( - { - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - mimeType: "image/png", - _meta: { "codex/imageDetail": "original" }, - }, - "high", -); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: Some(crate::ImageDetail::High), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helper_accepts_low_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - image_url: "data:image/png;base64,AAA", - detail: "low", -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: vec![FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(crate::ImageDetail::Low), - }], - error_text: None, - } - ); - } - - #[tokio::test] - async fn image_helpers_reject_remote_urls() { - for image_url in [ - "http://example.com/image.jpg", - "https://example.com/image.jpg", - ] { - for source in [ - format!("image({image_url:?});"), - format!("generatedImage({{ image_url: {image_url:?} }});"), - ] { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source, - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: Some( - "Tool call failed: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead".to_string(), - ), - } - ); - } - } - } - - #[tokio::test] - async fn image_helper_rejects_unsupported_detail() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - image_url: "data:image/png;base64,AAA", - detail: "medium", -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: Some( - "image detail must be one of: auto, low, high, original".to_string() - ), - } - ); - } - - #[tokio::test] - async fn image_helper_rejects_raw_mcp_result_container() { - let service = CodeModeService::new(); - - let response = execute( - &service, - ExecuteRequest { - source: r#" -image({ - content: [ - { - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - mimeType: "image/png", - _meta: { "codex/imageDetail": "original" }, - }, - ], - isError: false, -}); -"# - .to_string(), - yield_time_ms: None, - ..execute_request("") - }, - ) - .await; - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: cell_id("1"), - content_items: Vec::new(), - error_text: Some( - "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(), - ), - } - ); - } - - #[tokio::test] - async fn wait_reports_missing_cell_separately_from_runtime_results() { - let service = CodeModeService::new(); - - let response = service - .wait(WaitRequest { - cell_id: cell_id("missing"), - yield_time_ms: 1, - }) - .await - .unwrap(); - - assert_eq!( - response, - WaitOutcome::MissingCell(RuntimeResponse::Result { - cell_id: cell_id("missing"), - content_items: Vec::new(), - error_text: Some("exec cell missing not found".to_string()), - }) - ); - } - - #[tokio::test] - async fn terminate_waits_for_runtime_shutdown_before_responding() { - let inner = test_inner(); - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (control_tx, control_rx) = mpsc::unbounded_channel(); - let (initial_response_tx, initial_response_rx) = oneshot::channel(); - let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( - HashMap::new(), - ExecuteRequest { - source: "await new Promise(() => {})".to_string(), - yield_time_ms: None, - ..execute_request("") - }, - runtime_event_tx, - PendingRuntimeMode::Continue, - ) - .unwrap(); - - tokio::spawn(run_cell_control( - inner, - CellControlContext { - cell_id: cell_id("cell-1"), - runtime_tx: runtime_tx.clone(), - runtime_control_tx, - pending_mode: PendingRuntimeMode::Continue, - runtime_terminate_handle, - cancellation_token: tokio_util::sync::CancellationToken::new(), - }, - event_rx, - control_rx, - CellResponseSender::Runtime(initial_response_tx), - Some(/*initial_yield_time_ms*/ 60_000), - )); - - event_tx.send(RuntimeEvent::Started).unwrap(); - event_tx.send(RuntimeEvent::YieldRequested).unwrap(); - assert_eq!( - initial_response_rx.await.unwrap(), - RuntimeResponse::Yielded { - cell_id: cell_id("cell-1"), - content_items: Vec::new(), - } - ); - - let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); - control_tx - .send(CellControlCommand::Terminate { - response_tx: terminate_response_tx, - }) - .unwrap(); - let terminate_response = async { terminate_response_rx.await.unwrap() }; - tokio::pin!(terminate_response); - assert!( - tokio::time::timeout(Duration::from_millis(100), terminate_response.as_mut()) - .await - .is_err() - ); - - drop(event_tx); - - assert_eq!( - terminate_response.await, - RuntimeResponse::Terminated { - cell_id: cell_id("cell-1"), - content_items: Vec::new(), - } - ); - - let _ = runtime_tx.send(RuntimeCommand::Terminate); - } -} +#[cfg(test)] +#[path = "service_contract_tests.rs"] +mod contract_tests; diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs new file mode 100644 index 000000000000..8ead39640ad9 --- /dev/null +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -0,0 +1,530 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use codex_protocol::ToolName; +use pretty_assertions::assert_eq; +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::*; +use crate::CodeModeToolKind; +use crate::ToolDefinition; + +#[derive(Debug, PartialEq)] +enum DelegateEvent { + NotificationStarted, + NotificationCancelled, + ToolStarted, + ToolCancelled, + CellClosed(CellId), +} + +struct BlockingDelegate { + events_tx: mpsc::UnboundedSender, + notification_finished: AtomicBool, + tool_finished: AtomicBool, + tool_release: Notify, +} + +struct HeldNotificationDelegate { + events_tx: mpsc::UnboundedSender, + notification_release: Notify, +} + +impl HeldNotificationDelegate { + fn new() -> (Arc, mpsc::UnboundedReceiver) { + let (events_tx, events_rx) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + events_tx, + notification_release: Notify::new(), + }), + events_rx, + ) + } + + fn release_notification(&self) { + self.notification_release.notify_one(); + } +} + +impl CodeModeSessionDelegate for HeldNotificationDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + cancellation_token.cancelled().await; + Err("cancelled".to_string()) + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async move { + let _ = self.events_tx.send(DelegateEvent::NotificationStarted); + cancellation_token.cancelled().await; + let _ = self.events_tx.send(DelegateEvent::NotificationCancelled); + self.notification_release.notified().await; + Ok(()) + }) + } + + fn cell_closed(&self, cell_id: &CellId) { + let _ = self + .events_tx + .send(DelegateEvent::CellClosed(cell_id.clone())); + } +} + +impl BlockingDelegate { + fn new() -> (Arc, mpsc::UnboundedReceiver) { + let (events_tx, events_rx) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + events_tx, + notification_finished: AtomicBool::new(false), + tool_finished: AtomicBool::new(false), + tool_release: Notify::new(), + }), + events_rx, + ) + } + + fn release_tool(&self) { + self.tool_release.notify_one(); + } +} + +impl CodeModeSessionDelegate for BlockingDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + let _ = self.events_tx.send(DelegateEvent::ToolStarted); + tokio::select! { + _ = self.tool_release.notified() => { + self.tool_finished.store(true, Ordering::Release); + Ok(serde_json::Value::Null) + } + _ = cancellation_token.cancelled() => { + self.tool_finished.store(true, Ordering::Release); + let _ = self.events_tx.send(DelegateEvent::ToolCancelled); + Err("cancelled".to_string()) + } + } + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async move { + let _ = self.events_tx.send(DelegateEvent::NotificationStarted); + cancellation_token.cancelled().await; + self.notification_finished.store(true, Ordering::Release); + let _ = self.events_tx.send(DelegateEvent::NotificationCancelled); + Err("cancelled".to_string()) + }) + } + + fn cell_closed(&self, cell_id: &CellId) { + let _ = self + .events_tx + .send(DelegateEvent::CellClosed(cell_id.clone())); + } +} + +fn cell_id(value: &str) -> CellId { + CellId::new(value.to_string()) +} + +fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + yield_time_ms: Some(1), + max_output_tokens: None, + } +} + +fn blocking_tool() -> ToolDefinition { + ToolDefinition { + name: "block".to_string(), + tool_name: ToolName::plain("block"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + } +} + +async fn next_event(events_rx: &mut mpsc::UnboundedReceiver) -> DelegateEvent { + tokio::time::timeout(Duration::from_secs(2), events_rx.recv()) + .await + .expect("delegate event timeout") + .expect("delegate event channel closed") +} + +#[tokio::test] +async fn yields_and_resumes() { + let service = CodeModeService::new(); + let cell = service + .execute(ExecuteRequest { + source: r#"text("before"); yield_control(); text("after");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + } + ); + assert_eq!( + service + .wait(WaitRequest { + cell_id: cell_id("1"), + yield_time_ms: 60_000, + }) + .await + .unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + error_text: None, + }) + ); +} + +#[tokio::test] +async fn returns_and_resumes_from_the_pending_frontier() { + let (delegate, mut events_rx) = BlockingDelegate::new(); + let service = CodeModeService::with_delegate(delegate.clone()); + + assert_eq!( + service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![blocking_tool()], + source: r#" +await tools.block({}); +text("after"); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(), + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + } + ); + + assert_eq!(next_event(&mut events_rx).await, DelegateEvent::ToolStarted); + delegate.release_tool(); + + assert_eq!( + service + .wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }) + .await + .unwrap(), + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + error_text: None, + } + )) + ); +} + +#[tokio::test] +async fn observed_natural_completion_wins_over_termination() { + let service = CodeModeService::new(); + let cell = service + .execute(execute_request( + r#"yield_control(); store("finished", true); text("done");"#, + )) + .await + .unwrap(); + + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + 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; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + service.terminate(cell_id("1")).await.unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + }) + ); +} + +#[tokio::test] +async fn termination_cancels_pending_callbacks_before_responding() { + let (delegate, mut events_rx) = BlockingDelegate::new(); + let service = CodeModeService::with_delegate(delegate.clone()); + let cell = service + .execute(execute_request( + r#"notify("pending"); await new Promise(() => {});"#, + )) + .await + .unwrap(); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationStarted + ); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + assert_eq!( + service.terminate(cell_id("1")).await.unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); + assert!(delegate.notification_finished.load(Ordering::Acquire)); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationCancelled + ); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} + +#[tokio::test] +async fn shutdown_cancels_notifications_while_natural_completion_is_draining() { + let (delegate, mut events_rx) = HeldNotificationDelegate::new(); + let service = Arc::new(CodeModeService::with_delegate(delegate.clone())); + service + .execute(execute_request(r#"notify("pending");"#)) + .await + .unwrap(); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationStarted + ); + + let shutdown_service = Arc::clone(&service); + let shutdown = tokio::spawn(async move { shutdown_service.shutdown().await }); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationCancelled + ); + delegate.release_notification(); + + assert_eq!(shutdown.await.unwrap(), Ok(())); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} + +#[tokio::test] +async fn repeated_termination_is_rejected_while_callback_cleanup_is_pending() { + let (delegate, mut events_rx) = HeldNotificationDelegate::new(); + let service = Arc::new(CodeModeService::with_delegate(delegate.clone())); + let cell = service + .execute(execute_request( + r#"notify("pending"); await new Promise(() => {});"#, + )) + .await + .unwrap(); + + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationStarted + ); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + let terminating_service = Arc::clone(&service); + let first_termination = + tokio::spawn(async move { terminating_service.terminate(cell_id("1")).await }); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::NotificationCancelled + ); + + let repeated_termination = service.terminate(cell_id("1")).await; + delegate.release_notification(); + + assert_eq!( + repeated_termination.unwrap_err(), + "exec cell 1 is already terminating" + ); + assert_eq!( + first_termination.await.unwrap().unwrap(), + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} + +#[tokio::test] +async fn second_observer_is_rejected_without_displacing_the_first() { + let service = CodeModeService::new(); + let cell = service + .execute(execute_request("await new Promise(() => {});")) + .await + .unwrap(); + + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + let first_observer = service + .begin_wait(WaitRequest { + cell_id: cell_id("1"), + yield_time_ms: 60_000, + }) + .await; + assert_eq!( + service + .wait(WaitRequest { + cell_id: cell_id("1"), + yield_time_ms: 60_000, + }) + .await + .unwrap_err(), + "exec cell 1 already has an active observer" + ); + + let terminated = RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }; + assert_eq!( + service.terminate(cell_id("1")).await.unwrap(), + WaitOutcome::LiveCell(terminated.clone()) + ); + assert_eq!( + first_observer.await.unwrap(), + WaitOutcome::LiveCell(terminated) + ); +} + +#[tokio::test] +async fn natural_completion_cleans_up_callbacks_before_responding() { + let (delegate, mut events_rx) = BlockingDelegate::new(); + let service = CodeModeService::with_delegate(delegate.clone()); + let cell = service + .execute(ExecuteRequest { + enabled_tools: vec![blocking_tool()], + source: r#"tools.block({}); text("done");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!(next_event(&mut events_rx).await, DelegateEvent::ToolStarted); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + } + ); + assert!(delegate.tool_finished.load(Ordering::Acquire)); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::ToolCancelled + ); + assert_eq!( + next_event(&mut events_rx).await, + DelegateEvent::CellClosed(cell_id("1")) + ); +} diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode/src/service_tests.rs new file mode 100644 index 000000000000..6344bfdab994 --- /dev/null +++ b/codex-rs/code-mode/src/service_tests.rs @@ -0,0 +1,965 @@ +use std::sync::Arc; +use std::time::Duration; + +use super::CellId; +use super::CodeModeNestedToolCall; +use super::CodeModeService; +use super::CodeModeSessionDelegate; +use super::NotificationFuture; +use super::RuntimeResponse; +use super::ToolInvocationFuture; +use super::WaitOutcome; +use super::WaitRequest; +use super::WaitToPendingOutcome; +use super::WaitToPendingRequest; +use crate::CodeModeToolKind; +use crate::ExecuteRequest; +use crate::ExecuteToPendingOutcome; +use crate::FunctionCallOutputContentItem; +use crate::ToolDefinition; +use codex_protocol::ToolName; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct ReleasableToolDelegate { + tool_release: Notify, +} + +impl ReleasableToolDelegate { + fn release_tool(&self) { + self.tool_release.notify_one(); + } +} + +impl CodeModeSessionDelegate for ReleasableToolDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + tokio::select! { + _ = self.tool_release.notified() => Ok(JsonValue::Null), + _ = cancellation_token.cancelled() => Err("cancelled".to_string()), + } + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async { Ok(()) }) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + +fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + yield_time_ms: Some(1), + max_output_tokens: None, + } +} + +fn cell_id(value: &str) -> CellId { + CellId::new(value.to_string()) +} + +fn echo_tool() -> ToolDefinition { + ToolDefinition { + name: "echo".to_string(), + tool_name: ToolName::plain("echo"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + } +} + +async fn execute(service: &CodeModeService, request: ExecuteRequest) -> RuntimeResponse { + service + .execute(request) + .await + .unwrap() + .initial_response() + .await + .unwrap() +} + +#[tokio::test] +async fn synchronous_exit_returns_successfully() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#"text("before"); exit(); text("after");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn stored_values_are_shared_between_cells_but_not_sessions() { + let first_session = CodeModeService::new(); + let second_session = CodeModeService::new(); + + let write_response = execute( + &first_session, + ExecuteRequest { + source: r#"store("key", "visible");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + let same_session = execute( + &first_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + let other_session = execute( + &second_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + write_response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: None, + } + ); + assert_eq!( + same_session, + RuntimeResponse::Result { + cell_id: cell_id("2"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "visible".to_string(), + }], + error_text: None, + } + ); + assert_eq!( + other_session, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "undefined".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn shutdown_interrupts_cpu_bound_cells() { + let service = CodeModeService::new(); + + let cell = service + .execute(ExecuteRequest { + source: "while (true) {}".to_string(), + ..execute_request("") + }) + .await + .unwrap(); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + tokio::time::timeout(Duration::from_secs(1), service.shutdown()) + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn start_cell_rejects_new_cell_after_shutdown_begins() { + let service = CodeModeService::new(); + service.shutdown().await.unwrap(); + + let error = service + .execute(execute_request("text('late');")) + .await + .err() + .unwrap(); + + assert_eq!(error, "code mode session is shutting down".to_string()); +} + +#[tokio::test] +async fn execute_to_pending_returns_completed_for_synchronous_results() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + source: r#"text("done");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + }) + ); +} + +#[tokio::test] +async fn execute_to_pending_returns_once_the_runtime_is_quiescent() { + let service = CodeModeService::new(); + + let response = tokio::time::timeout( + Duration::from_secs(1), + service.execute_to_pending(ExecuteRequest { + source: r#"text("before"); await new Promise(() => {});"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + pending_tool_call_ids: Vec::new(), + } + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +await Promise.all([ + tools.echo({ value: "first" }), + tools.echo({ value: "second" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +setTimeout(() => { + tools.echo({ value: "delayed" }); +}, 1000); +await Promise.all([ + tools.echo({ value: "second" }), + tools.echo({ value: "third" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-3".to_string()], + }) + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() { + let delegate = Arc::new(ReleasableToolDelegate::default()); + let service = CodeModeService::with_delegate(delegate.clone()); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +await tools.echo({}); +text("after"); +await new Promise(() => {}); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + } + ); + + delegate.release_tool(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + pending_tool_call_ids: Vec::new(), + }) + ); + + let termination = service.terminate(cell_id("1")).await.unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: cell_id("1"), + content_items: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() { + let delegate = Arc::new(ReleasableToolDelegate::default()); + let service = CodeModeService::with_delegate(delegate.clone()); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![echo_tool()], + source: r#" +await tools.echo({}); +text("done"); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: cell_id("1"), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + } + ); + + delegate.release_tool(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: cell_id("1"), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + error_text: None, + } + )) + ); +} + +#[tokio::test] +async fn v8_console_is_not_exposed_on_global_this() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "false".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn date_locale_string_formats_with_icu_data() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +const value = new Date("2025-01-02T03:04:05Z") + .toLocaleString("fr-FR", { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", + }); +text(value); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn intl_date_time_format_formats_with_icu_data() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +const formatter = new Intl.DateTimeFormat("fr-FR", { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", +}); +text(formatter.format(new Date("2025-01-02T03:04:05Z"))); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn output_helpers_return_undefined() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +const returnsUndefined = [ + text("first"), + image("data:image/png;base64,AAA"), + notify("ping"), +].map((value) => value === undefined); +text(JSON.stringify(returnsUndefined)); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![ + FunctionCallOutputContentItem::InputText { + text: "first".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "[true,true,true]".to_string(), + }, + ], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: Some(crate::ImageDetail::Original), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn generated_image_helper_appends_image_and_output_hint() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +generatedImage({ + image_url: "data:image/png;base64,AAA", + output_hint: "generated image save hint", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "generated image save hint".to_string(), + }, + ], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_second_arg_overrides_explicit_object_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image( + { + image_url: "data:image/png;base64,AAA", + detail: "high", + }, + "original", +); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::ImageDetail::Original), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_second_arg_overrides_raw_mcp_image_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image( + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, + }, + "high", +); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: Some(crate::ImageDetail::High), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helper_accepts_low_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + image_url: "data:image/png;base64,AAA", + detail: "low", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(crate::ImageDetail::Low), + }], + error_text: None, + } + ); +} + +#[tokio::test] +async fn image_helpers_reject_remote_urls() { + for image_url in [ + "http://example.com/image.jpg", + "https://example.com/image.jpg", + ] { + for source in [ + format!("image({image_url:?});"), + format!("generatedImage({{ image_url: {image_url:?} }});"), + ] { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source, + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: Some( + "Tool call failed: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead".to_string(), + ), + } + ); + } + } +} + +#[tokio::test] +async fn image_helper_rejects_unsupported_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + image_url: "data:image/png;base64,AAA", + detail: "medium", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: Some("image detail must be one of: auto, low, high, original".to_string()), + } + ); +} + +#[tokio::test] +async fn image_helper_rejects_raw_mcp_result_container() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + content: [ + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, + }, + ], + isError: false, +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: Some( + "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(), + ), + } + ); +} + +#[tokio::test] +async fn wait_reports_missing_cell_separately_from_runtime_results() { + let service = CodeModeService::new(); + + let response = service + .wait(WaitRequest { + cell_id: cell_id("missing"), + yield_time_ms: 1, + }) + .await + .unwrap(); + + assert_eq!( + response, + WaitOutcome::MissingCell(RuntimeResponse::Result { + cell_id: cell_id("missing"), + content_items: Vec::new(), + error_text: Some("exec cell missing not found".to_string()), + }) + ); +} 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 000000000000..0f7557a750fa --- /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 000000000000..e0838d89d802 --- /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 000000000000..b04bd0f2364b --- /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/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 3b34b1569b93..72aa66a8101f 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -32,7 +32,6 @@ url = { workspace = true } anyhow = { workspace = true } assert_matches = { workspace = true } pretty_assertions = { workspace = true } -tempfile = { workspace = true } tokio-test = { workspace = true } wiremock = { workspace = true } reqwest = { workspace = true } diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 17753e799ad8..aeea85e7fc8b 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -72,6 +72,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 +114,12 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct SafetyBuffering { + pub use_cases: Vec, + pub reasons: Vec, +} + #[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ReasoningContext { diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index c8730235fd5f..fd3fd17dbca7 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -9,7 +9,6 @@ use codex_protocol::models::ResponseItem; use http::HeaderMap; use http::Method; use serde::Deserialize; -use serde_json::to_value; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; @@ -76,7 +75,7 @@ impl CompactClient { request_timeout: Duration, turn_state: Option<&OnceLock>, ) -> Result, ApiError> { - let body = to_value(input) + let body = serde_json::to_value(input) .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; self.compact(body, extra_headers, request_timeout, turn_state) .await diff --git a/codex-rs/codex-api/src/endpoint/realtime_call.rs b/codex-rs/codex-api/src/endpoint/realtime_call.rs index e2ade0266c10..af706091360f 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_call.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_call.rs @@ -1,4 +1,5 @@ use crate::auth::SharedAuthProvider; +use crate::endpoint::realtime_websocket::RealtimeEventParser; use crate::endpoint::realtime_websocket::RealtimeSessionConfig; use crate::endpoint::realtime_websocket::session_update_session_json; use crate::endpoint::session::EndpointSession; @@ -9,7 +10,6 @@ use codex_client::HttpTransport; use codex_client::Request; use codex_client::RequestBody; use codex_client::RequestTelemetry; -use codex_protocol::protocol::RealtimeConversationArchitecture; use http::HeaderMap; use http::HeaderValue; use http::Method; @@ -120,27 +120,12 @@ impl RealtimeCallClient { sdp: String, session_config: RealtimeSessionConfig, extra_headers: HeaderMap, - ) -> Result { - self.create_with_session_architecture_and_headers( - sdp, - session_config, - RealtimeConversationArchitecture::RealtimeApi, - extra_headers, - ) - .await - } - - pub async fn create_with_session_architecture_and_headers( - &self, - sdp: String, - session_config: RealtimeSessionConfig, - architecture: RealtimeConversationArchitecture, - extra_headers: HeaderMap, ) -> Result { trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}"); // WebRTC can begin inference as soon as the peer connection comes up, so the initial // session payload is sent with call creation. The sideband WebSocket still sends its normal // session.update after it joins. + validate_avas_session_config(&session_config)?; let mut session = realtime_session_json(session_config)?; if let Some(session) = session.as_object_mut() { session.remove("id"); @@ -159,7 +144,7 @@ impl RealtimeCallClient { Self::path(), extra_headers, Some(body), - |req| configure_realtime_call_request(req, architecture), + configure_realtime_call_request, ) .await?; let sdp = decode_sdp_response(resp.body.as_ref())?; @@ -191,7 +176,7 @@ impl RealtimeCallClient { extra_headers, /*body*/ None, |req| { - configure_realtime_call_request(req, architecture); + configure_realtime_call_request(req); req.headers.insert( CONTENT_TYPE, HeaderValue::from_static(MULTIPART_CONTENT_TYPE), @@ -208,17 +193,18 @@ impl RealtimeCallClient { } } -fn configure_realtime_call_request( - request: &mut Request, - architecture: RealtimeConversationArchitecture, -) { - match architecture { - RealtimeConversationArchitecture::RealtimeApi => {} - RealtimeConversationArchitecture::Avas => { - append_query_pair(&mut request.url, "intent", "quicksilver"); - append_query_pair(&mut request.url, "architecture", "avas"); - } +fn configure_realtime_call_request(request: &mut Request) { + append_query_pair(&mut request.url, "intent", "quicksilver"); + append_query_pair(&mut request.url, "architecture", "avas"); +} + +fn validate_avas_session_config(session_config: &RealtimeSessionConfig) -> Result<(), ApiError> { + if session_config.event_parser != RealtimeEventParser::V1 { + return Err(ApiError::InvalidRequest { + message: "AVAS realtime calls require realtime v1".to_string(), + }); } + Ok(()) } fn append_query_pair(url: &mut String, key: &str, value: &str) { @@ -377,10 +363,18 @@ mod tests { instructions: "hi".to_string(), model: Some("gpt-realtime".to_string()), session_id: Some(session_id.to_string()), - event_parser: RealtimeEventParser::RealtimeV2, + event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + } + } + + fn realtime_v2_session_config(session_id: &str) -> RealtimeSessionConfig { + RealtimeSessionConfig { + event_parser: RealtimeEventParser::RealtimeV2, voice: RealtimeVoice::Marin, + ..realtime_session_config(session_id) } } @@ -488,7 +482,10 @@ mod tests { let request = transport.last_request.lock().unwrap().clone().unwrap(); assert_eq!(request.method, Method::POST); - assert_eq!(request.url, "https://api.openai.com/v1/realtime/calls"); + assert_eq!( + request.url, + "https://api.openai.com/v1/realtime/calls?intent=quicksilver&architecture=avas" + ); assert_eq!( request.headers.get(CONTENT_TYPE).unwrap(), HeaderValue::from_static(MULTIPART_CONTENT_TYPE) @@ -524,7 +521,7 @@ mod tests { } #[tokio::test] - async fn sends_avas_session_call_query_params() { + async fn sends_session_call_with_avas_query_params() { let transport = CapturingTransport::new(); let client = RealtimeCallClient::new( transport.clone(), @@ -533,10 +530,9 @@ mod tests { ); let response = client - .create_with_session_architecture_and_headers( + .create_with_session_and_headers( "v=offer\r\n".to_string(), realtime_session_config("sess-api"), - RealtimeConversationArchitecture::Avas, HeaderMap::new(), ) .await @@ -558,6 +554,30 @@ mod tests { ); } + #[tokio::test] + async fn rejects_v2_session_call_before_sending_request() { + let transport = CapturingTransport::new(); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://api.openai.com/v1"), + Arc::new(DummyAuth), + ); + + let err = client + .create_with_session( + "v=offer\r\n".to_string(), + realtime_v2_session_config("sess-api"), + ) + .await + .expect_err("v2 session config should be rejected"); + + assert_eq!( + err.to_string(), + "invalid request: AVAS realtime calls require realtime v1" + ); + assert!(transport.last_request.lock().unwrap().is_none()); + } + #[tokio::test] async fn sends_backend_session_call_as_json_body() { let transport = CapturingTransport::new(); @@ -587,7 +607,7 @@ mod tests { assert_eq!(request.method, Method::POST); assert_eq!( request.url, - "https://chatgpt.com/backend-api/codex/realtime/calls" + "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas" ); let mut expected_session = realtime_session_json(realtime_session_config("sess-backend")) .expect("session should encode"); diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 0aa2feb04e76..499635579f00 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -1637,10 +1637,29 @@ mod tests { .into_text() .expect("text"); let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); - assert_eq!(fourth_json["type"], "conversation.handoff.append"); - assert_eq!(fourth_json["handoff_id"], "handoff_1"); + assert_eq!(fourth_json["type"], "conversation.item.create"); + assert_eq!(fourth_json["item"]["role"], "assistant"); assert_eq!( - fourth_json["output_text"], + fourth_json["item"]["content"][0]["type"], + Value::String("output_text".to_string()) + ); + assert_eq!( + fourth_json["item"]["content"][0]["text"], + Value::String("assistant context".to_string()) + ); + + let fifth = ws + .next() + .await + .expect("fifth msg") + .expect("fifth msg ok") + .into_text() + .expect("text"); + let fifth_json: Value = serde_json::from_str(&fifth).expect("json"); + assert_eq!(fifth_json["type"], "conversation.handoff.append"); + assert_eq!(fifth_json["handoff_id"], "handoff_1"); + assert_eq!( + fifth_json["output_text"], "\"Agent Final Message\":\n\nhello from background agent" ); @@ -1766,6 +1785,13 @@ mod tests { ) .await .expect("send item"); + connection + .send_conversation_item_create( + "assistant context".to_string(), + ConversationTextRole::Assistant, + ) + .await + .expect("send assistant item"); connection .send_conversation_function_call_output( "handoff_1".to_string(), @@ -1988,16 +2014,35 @@ mod tests { .expect("text"); let third_json: Value = serde_json::from_str(&third).expect("json"); assert_eq!(third_json["type"], "conversation.item.create"); + assert_eq!(third_json["item"]["role"], "assistant"); + assert_eq!( + third_json["item"]["content"][0]["type"], + Value::String("output_text".to_string()) + ); assert_eq!( - third_json["item"]["type"], + third_json["item"]["content"][0]["text"], + Value::String("assistant context".to_string()) + ); + + let fourth = ws + .next() + .await + .expect("fourth msg") + .expect("fourth msg ok") + .into_text() + .expect("text"); + let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); + assert_eq!(fourth_json["type"], "conversation.item.create"); + assert_eq!( + fourth_json["item"]["type"], Value::String("function_call_output".to_string()) ); assert_eq!( - third_json["item"]["call_id"], + fourth_json["item"]["call_id"], Value::String("call_1".to_string()) ); assert_eq!( - third_json["item"]["output"], + fourth_json["item"]["output"], Value::String("delegated result".to_string()) ); }); @@ -2054,6 +2099,13 @@ mod tests { ) .await .expect("send text item"); + connection + .send_conversation_item_create( + "assistant context".to_string(), + ConversationTextRole::Assistant, + ) + .await + .expect("send assistant item"); connection .send_conversation_function_call_output( "call_1".to_string(), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs index a7f73d82f3c6..aa063d07c67f 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs @@ -19,12 +19,19 @@ pub(super) fn conversation_item_create_message( text: String, role: ConversationTextRole, ) -> RealtimeOutboundMessage { + let content_type = match role { + ConversationTextRole::Assistant => ConversationContentType::OutputText, + ConversationTextRole::User | ConversationTextRole::Developer => { + ConversationContentType::InputText + } + }; + RealtimeOutboundMessage::ConversationItemCreate { item: ConversationItemPayload::Message(ConversationMessageItem { r#type: ConversationItemType::Message, role, content: vec![ConversationItemContent { - r#type: ConversationContentType::InputText, + r#type: content_type, text, }], }), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs index 702b4be8692e..ee5d5031affa 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs @@ -40,12 +40,19 @@ pub(super) fn conversation_item_create_message( text: String, role: ConversationTextRole, ) -> RealtimeOutboundMessage { + let content_type = match role { + ConversationTextRole::Assistant => ConversationContentType::OutputText, + ConversationTextRole::User | ConversationTextRole::Developer => { + ConversationContentType::InputText + } + }; + RealtimeOutboundMessage::ConversationItemCreate { item: ConversationItemPayload::Message(ConversationMessageItem { r#type: ConversationItemType::Message, role, content: vec![ConversationItemContent { - r#type: ConversationContentType::InputText, + r#type: content_type, text, }], }), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index 8f61fc1d1d6b..48f89b0d33dd 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -195,6 +195,7 @@ pub(super) struct ConversationItemContent { #[serde(rename_all = "snake_case")] pub(super) enum ConversationContentType { InputText, + OutputText, } #[derive(Debug, Clone, Serialize)] diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index ad79adf33dff..804f0027ff84 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -5,7 +5,6 @@ use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; use crate::requests::Compression; -use crate::requests::attach_item_ids; use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; @@ -82,16 +81,8 @@ impl ResponsesClient { turn_state, } = options; - let body = if request.store && self.session.provider().is_azure_responses_endpoint() { - let mut body = serde_json::to_value(&request).map_err(|e| { - ApiError::Stream(format!("failed to encode responses request: {e}")) - })?; - attach_item_ids(&mut body, &request.input); - EncodedJsonBody::encode(&body) - } else { - EncodedJsonBody::encode(&request) - } - .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; + let body = EncodedJsonBody::encode(&request) + .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; let mut headers = extra_headers; if let Some(ref thread_id) = thread_id { diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 57309deae928..4dc7a527dc5b 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -669,7 +669,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()) @@ -691,6 +690,7 @@ async fn run_websocket_response_stream( } let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); + let safety_buffering = event.safety_buffering(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { latest_rate_limit_snapshot = Some(snapshot.clone()); @@ -726,6 +726,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, latest_rate_limit_snapshot.as_ref()) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); @@ -817,7 +827,7 @@ mod tests { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: vec![json!({ "type": "function", diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 1c231d6bef85..d33e502ce3f6 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -55,6 +55,7 @@ mod tests { use crate::provider::RetryConfig; use crate::search::AllowedCaller; use crate::search::ApproximateLocation; + use crate::search::ExternalWebAccess; use crate::search::LocationType; use crate::search::OpenOperation; use crate::search::SearchCommands; @@ -149,7 +150,7 @@ mod tests { model: "gpt-test".to_string(), reasoning: None, input: Some(SearchInput::Items(vec![ResponseItem::Message { - id: None, + id: Some("msg_search".to_string()), role: "user".to_string(), content: vec![ ContentItem::InputText { @@ -161,7 +162,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }])), commands: Some(SearchCommands { search_query: Some(vec![SearchQuery { @@ -193,7 +194,7 @@ mod tests { caption: Some(true), }), allowed_callers: Some(vec![AllowedCaller::Direct]), - external_web_access: Some(true), + external_web_access: Some(ExternalWebAccess::Boolean(true)), }), max_output_tokens: Some(2500), }, @@ -228,6 +229,7 @@ mod tests { "model": "gpt-test", "input": [{ "type": "message", + "id": "msg_search", "role": "user", "content": [ {"type": "input_text", "text": "find this"}, diff --git a/codex-rs/codex-api/src/files.rs b/codex-rs/codex-api/src/files.rs index d1e2840066d8..8bcf1a4d0d58 100644 --- a/codex-rs/codex-api/src/files.rs +++ b/codex-rs/codex-api/src/files.rs @@ -1,15 +1,13 @@ -use std::path::Path; -use std::path::PathBuf; use std::time::Duration; use crate::AuthProvider; +use bytes::Bytes; use codex_client::build_reqwest_client_with_custom_ca; +use futures::Stream; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; use serde::Deserialize; -use tokio::fs::File; use tokio::time::Instant; -use tokio_util::io::ReaderStream; pub const OPENAI_FILE_URI_PREFIX: &str = "sediment://"; pub const OPENAI_FILE_UPLOAD_LIMIT_BYTES: u64 = 512 * 1024 * 1024; @@ -27,26 +25,15 @@ pub struct UploadedOpenAiFile { pub file_name: String, pub file_size_bytes: u64, pub mime_type: Option, - pub path: PathBuf, } #[derive(Debug, thiserror::Error)] pub enum OpenAiFileError { - #[error("path `{path}` does not exist")] - MissingPath { path: PathBuf }, - #[error("path `{path}` is not a file")] - NotAFile { path: PathBuf }, - #[error("path `{path}` cannot be read: {source}")] - ReadFile { - path: PathBuf, - #[source] - source: std::io::Error, - }, #[error( - "file `{path}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes" + "file `{file_name}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes" )] FileTooLarge { - path: PathBuf, + file_name: String, size_bytes: u64, limit_bytes: u64, }, @@ -94,45 +81,26 @@ pub fn openai_file_uri(file_id: &str) -> String { format!("{OPENAI_FILE_URI_PREFIX}{file_id}") } -pub async fn upload_local_file( +pub async fn upload_openai_file( base_url: &str, auth: &dyn AuthProvider, - path: &Path, + file_name: String, + file_size_bytes: u64, + contents: impl Stream> + Send + 'static, ) -> Result { - let metadata = tokio::fs::metadata(path) - .await - .map_err(|source| match source.kind() { - std::io::ErrorKind::NotFound => OpenAiFileError::MissingPath { - path: path.to_path_buf(), - }, - _ => OpenAiFileError::ReadFile { - path: path.to_path_buf(), - source, - }, - })?; - if !metadata.is_file() { - return Err(OpenAiFileError::NotAFile { - path: path.to_path_buf(), - }); - } - if metadata.len() > OPENAI_FILE_UPLOAD_LIMIT_BYTES { + if file_size_bytes > OPENAI_FILE_UPLOAD_LIMIT_BYTES { return Err(OpenAiFileError::FileTooLarge { - path: path.to_path_buf(), - size_bytes: metadata.len(), + file_name, + size_bytes: file_size_bytes, limit_bytes: OPENAI_FILE_UPLOAD_LIMIT_BYTES, }); } - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("file") - .to_string(); let create_url = format!("{}/files", base_url.trim_end_matches('/')); let create_response = authorized_request(auth, reqwest::Method::POST, &create_url) .json(&serde_json::json!({ - "file_name": file_name, - "file_size": metadata.len(), + "file_name": file_name.as_str(), + "file_size": file_size_bytes, "use_case": OPENAI_FILE_USE_CASE, })) .send() @@ -156,18 +124,12 @@ pub async fn upload_local_file( source, })?; - let upload_file = File::open(path) - .await - .map_err(|source| OpenAiFileError::ReadFile { - path: path.to_path_buf(), - source, - })?; let upload_response = build_reqwest_client() .put(&create_payload.upload_url) .timeout(OPENAI_FILE_REQUEST_TIMEOUT) .header("x-ms-blob-type", "BlockBlob") - .header(CONTENT_LENGTH, metadata.len()) - .body(reqwest::Body::wrap_stream(ReaderStream::new(upload_file))) + .header(CONTENT_LENGTH, file_size_bytes) + .body(reqwest::Body::wrap_stream(contents)) .send() .await .map_err(|source| OpenAiFileError::Request { @@ -226,9 +188,8 @@ pub async fn upload_local_file( } })?, file_name: finalize_payload.file_name.unwrap_or(file_name), - file_size_bytes: metadata.len(), + file_size_bytes, mime_type: finalize_payload.mime_type, - path: path.to_path_buf(), }); } "retry" => { @@ -281,7 +242,6 @@ mod tests { use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; @@ -313,7 +273,7 @@ mod tests { } #[tokio::test] - async fn upload_local_file_returns_canonical_uri() { + async fn upload_openai_file_returns_canonical_uri() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) @@ -359,13 +319,17 @@ mod tests { .await; let base_url = base_url_for(&server); - let dir = TempDir::new().expect("temp dir"); - let path = dir.path().join("hello.txt"); - tokio::fs::write(&path, b"hello").await.expect("write file"); - - let uploaded = upload_local_file(&base_url, &chatgpt_auth(), &path) - .await - .expect("upload succeeds"); + let contents = + futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"hello"))]); + let uploaded = upload_openai_file( + &base_url, + &chatgpt_auth(), + "hello.txt".to_string(), + /*file_size_bytes*/ 5, + contents, + ) + .await + .expect("upload succeeds"); assert_eq!(uploaded.file_id, "file_123"); assert_eq!(uploaded.uri, "sediment://file_123"); diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index cc9b58c0343b..4881e77fbc22 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -65,7 +65,8 @@ pub use crate::endpoint::ResponsesWebsocketProbe; pub use crate::endpoint::SearchClient; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; -pub use crate::files::upload_local_file; +pub use crate::files::OPENAI_FILE_UPLOAD_LIMIT_BYTES; +pub use crate::files::upload_openai_file; pub use crate::images::ImageBackground; pub use crate::images::ImageData; pub use crate::images::ImageEditRequest; @@ -80,6 +81,8 @@ pub use crate::requests::Compression; pub use crate::search::AllowedCaller; pub use crate::search::ApproximateLocation; pub use crate::search::ClickOperation; +pub use crate::search::ExternalWebAccess; +pub use crate::search::ExternalWebAccessMode; pub use crate::search::FinanceAssetType; pub use crate::search::FinanceOperation; pub use crate::search::FindOperation; diff --git a/codex-rs/codex-api/src/requests/mod.rs b/codex-rs/codex-api/src/requests/mod.rs index 1c357b2a613b..abe57a886dce 100644 --- a/codex-rs/codex-api/src/requests/mod.rs +++ b/codex-rs/codex-api/src/requests/mod.rs @@ -2,4 +2,3 @@ pub(crate) mod headers; pub(crate) mod responses; pub use responses::Compression; -pub(crate) use responses::attach_item_ids; diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 5f3e1ba87618..9a16ceb1c44c 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -1,37 +1,6 @@ -use codex_protocol::models::ResponseItem; -use serde_json::Value; - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Compression { #[default] None, Zstd, } - -pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { - let Some(input_value) = payload_json.get_mut("input") else { - return; - }; - let Value::Array(items) = input_value else { - return; - }; - - for (value, item) in items.iter_mut().zip(original_items.iter()) { - if let ResponseItem::Reasoning { id, .. } - | ResponseItem::Message { id: Some(id), .. } - | ResponseItem::WebSearchCall { id: Some(id), .. } - | ResponseItem::FunctionCall { id: Some(id), .. } - | ResponseItem::ToolSearchCall { id: Some(id), .. } - | ResponseItem::LocalShellCall { id: Some(id), .. } - | ResponseItem::CustomToolCall { id: Some(id), .. } = item - { - if id.is_empty() { - continue; - } - - if let Some(obj) = value.as_object_mut() { - obj.insert("id".to_string(), Value::String(id.clone())); - } - } - } -} diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index bae7c8a7da4c..7219684af1ae 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -211,6 +211,21 @@ pub enum SearchResponseLength { Long, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExternalWebAccessMode { + Cached, + Indexed, + Live, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ExternalWebAccess { + Boolean(bool), + Mode(ExternalWebAccessMode), +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct SearchSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -224,7 +239,7 @@ pub struct SearchSettings { #[serde(skip_serializing_if = "Option::is_none")] pub allowed_callers: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub external_web_access: Option, + pub external_web_access: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 5f9bb5cd7abc..8b25d9558fdb 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -1,5 +1,6 @@ use crate::common::ResponseEvent; use crate::common::ResponseStream; +use crate::common::SafetyBuffering; use crate::error::ApiError; use crate::rate_limits::parse_all_rate_limits; use crate::rate_limits::parse_rate_limit_for_limit; @@ -179,6 +180,7 @@ pub struct ResponsesStreamEvent { delta: Option, summary_index: Option, content_index: Option, + safety_buffering: Option, } impl ResponsesStreamEvent { @@ -239,6 +241,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 { @@ -508,6 +514,7 @@ 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(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -537,6 +544,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, usage_limit_snapshot.as_ref()) { Ok(Some(event)) => { @@ -1364,6 +1379,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 d8489ed7487f..2d69f838603d 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -301,7 +301,7 @@ async fn responses_client_uses_responses_path() -> Result<()> { } #[tokio::test] -async fn responses_client_stream_request_preserves_exact_json_body() -> Result<()> { +async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); @@ -313,7 +313,7 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: Vec::new(), tool_choice: "auto".into(), @@ -327,7 +327,7 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( text: None, client_metadata: None, }; - let expected = serde_json::to_vec(&request)?; + let expected = serde_json::to_value(&request)?; let _stream = client .stream_request(request, ResponsesOptions::default()) @@ -338,7 +338,10 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( let prepared = requests[0] .prepare_body_for_send() .expect("body should prepare"); - assert_eq!(prepared.body.as_deref(), Some(expected.as_slice())); + let body: serde_json::Value = + serde_json::from_slice(prepared.body.as_deref().expect("body should be JSON"))?; + assert_eq!(body, expected); + assert_eq!(body["input"][0]["id"], "msg_1"); assert_eq!( prepared.headers.get(http::header::CONTENT_TYPE), Some(&HeaderValue::from_static("application/json")) @@ -502,7 +505,7 @@ async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> { } #[tokio::test] -async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { +async fn azure_store_sends_ids_and_headers() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth)); @@ -515,7 +518,7 @@ async fn azure_default_store_attaches_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(), tool_choice: "auto".into(), diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index e7c5edfc7d14..78daa6c28817 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 000000000000..a5bef21bcbe5 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -0,0 +1,322 @@ +//! 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; +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; + +/// 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. +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)?; + 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>, +) -> 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 }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow( + dead_code, + reason = "Direct and Proxy are constructed by platform resolvers added in later PRs" +)] +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 +} + +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 cached = cache.get(request_url)?; + if cached.expires_at > Instant::now() { + return Some(cached.decision.clone()); + } + cache.remove(request_url); + 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() { + insert_system_proxy_cache_entry(&mut cache, request_url, decision, Instant::now()); + } +} + +fn insert_system_proxy_cache_entry( + cache: &mut HashMap, + request_url: &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(request_url) + && let Some(request_url_to_evict) = cache + .iter() + .min_by_key(|(_, cached)| cached.expires_at) + .map(|(request_url, _)| request_url.clone()) + { + cache.remove(&request_url_to_evict); + } + cache.insert( + request_url.to_string(), + CachedSystemProxyDecision { + decision, + expires_at: now + ttl, + }, + ); +} + +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_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs new file mode 100644 index 000000000000..9c713d28f1a0 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -0,0 +1,133 @@ +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), + ) + .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 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); +} diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index 433cbde8b818..15866e49e45c 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -26,6 +26,7 @@ codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } futures = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 3196a143e8e3..b22a45f0d1a0 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -15,7 +15,6 @@ use crate::tools::ToolInfo; use anyhow::Context; use codex_login::CodexAuth; use codex_protocol::mcp::McpServerInfo; -use codex_utils_plugins::mcp_connector::is_connector_id_allowed; use codex_utils_plugins::mcp_connector::sanitize_name; use serde::Deserialize; use serde::Serialize; @@ -220,7 +219,7 @@ pub(crate) fn load_cached_codex_apps_tools( if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { return CachedCodexAppsToolsLoad::Invalid; } - CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) + CachedCodexAppsToolsLoad::Hit(cache.tools) } pub(crate) fn write_cached_codex_apps_tools( @@ -233,10 +232,9 @@ pub(crate) fn write_cached_codex_apps_tools( { return; } - let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, - tools, + tools: tools.to_vec(), }) else { return; }; @@ -279,17 +277,6 @@ fn write_cached_codex_apps_server_info( Ok(()) } -pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { - tools - .into_iter() - .filter(|tool| { - tool.connector_id - .as_deref() - .is_none_or(is_connector_id_allowed) - }) - .collect() -} - #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexAppsToolsDiskCache { schema_version: u8, @@ -303,7 +290,7 @@ struct CodexAppsServerInfoDiskCache { } const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; -pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4; const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 359648656807..ea55d213797e 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -133,6 +133,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, @@ -209,6 +210,7 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), + supports_openai_form_elicitation, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); @@ -370,6 +372,12 @@ impl McpConnectionManager { .map(super::server::McpServerOrigin::as_str) } + pub fn server_environment_id(&self, server_name: &str) -> Option<&str> { + self.server_metadata + .get(server_name) + .map(|metadata| metadata.environment_id.as_str()) + } + pub fn server_pollutes_memory(&self, server_name: &str) -> bool { self.server_metadata .get(server_name) @@ -539,14 +547,20 @@ impl McpConnectionManager { )) } - /// Returns a single map that contains all resources. Each key is the - /// server name and the value is a vector of resources. - pub async fn list_all_resources(&self) -> HashMap> { + /// Returns resources from servers selected by `include_server`. Each key + /// is the server name and the value is a vector of resources. + pub async fn list_all_resources( + &self, + include_server: impl Fn(&str) -> bool, + ) -> HashMap> { let mut join_set = JoinSet::new(); let clients_snapshot = &self.clients; - for (server_name, async_managed_client) in clients_snapshot { + for (server_name, async_managed_client) in clients_snapshot + .iter() + .filter(|(server_name, _)| include_server(server_name)) + { let server_name = server_name.clone(); let Ok(managed_client) = async_managed_client.client().await else { continue; @@ -604,14 +618,20 @@ impl McpConnectionManager { aggregated } - /// Returns a single map that contains all resource templates. Each key is the - /// server name and the value is a vector of resource templates. - pub async fn list_all_resource_templates(&self) -> HashMap> { + /// Returns resource templates from servers selected by `include_server`. + /// Each key is the server name and the value is a vector of templates. + pub async fn list_all_resource_templates( + &self, + include_server: impl Fn(&str) -> bool, + ) -> HashMap> { let mut join_set = JoinSet::new(); let clients_snapshot = &self.clients; - for (server_name, async_managed_client) in clients_snapshot { + for (server_name, async_managed_client) in clients_snapshot + .iter() + .filter(|(server_name, _)| include_server(server_name)) + { let server_name_cloned = server_name.clone(); let Ok(managed_client) = async_managed_client.client().await else { continue; diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index bfbc09c473c0..52194b07f1d8 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -251,13 +251,15 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { let response = sender( NumberOrString::Number(1), - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "Confirm?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, + codex_rmcp_client::Elicitation::Mcp( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Confirm?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + ), ) .await .expect("elicitation should auto accept"); @@ -284,17 +286,19 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel let response = sender( NumberOrString::Number(1), - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "What should I say?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .required_property( - "message", - rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), - ) - .build() - .expect("schema should build"), - }, + codex_rmcp_client::Elicitation::Mcp( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "What should I say?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .required_property( + "message", + rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), + ) + .build() + .expect("schema should build"), + }, + ), ) .await .expect("elicitation should auto decline"); @@ -608,7 +612,7 @@ fn codex_apps_tools_cache_is_scoped_per_user() { } #[test] -fn codex_apps_tools_cache_filters_disallowed_connectors() { +fn codex_apps_tools_cache_preserves_formerly_disallowed_connectors() { let codex_home = tempdir().expect("tempdir"); let cache_context = create_codex_apps_tools_cache_context( codex_home.path().to_path_buf(), @@ -618,13 +622,13 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() { let tools = vec![ create_test_tool_with_connector( CODEX_APPS_MCP_SERVER_NAME, - "blocked_tool", + "formerly_blocked_tool", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - Some("Blocked"), + Some("Formerly Blocked"), ), create_test_tool_with_connector( CODEX_APPS_MCP_SERVER_NAME, - "allowed_tool", + "calendar_tool", "calendar", Some("Calendar"), ), @@ -633,9 +637,19 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() { write_cached_codex_apps_tools(&cache_context, &tools); let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); - assert_eq!(cached.len(), 1); - assert_eq!(cached[0].callable_name, "allowed_tool"); - assert_eq!(cached[0].connector_id.as_deref(), Some("calendar")); + assert_eq!( + cached + .iter() + .map(|tool| (tool.callable_name.as_str(), tool.connector_id.as_deref())) + .collect::>(), + vec![ + ( + "formerly_blocked_tool", + Some("connector_2b0a9009c9c64bf9933a3dae3f2b1254") + ), + ("calendar_tool", Some("calendar")), + ] + ); } #[test] @@ -1127,6 +1141,7 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { manager.server_metadata.insert( server_name.to_string(), McpServerMetadata { + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), pollutes_memory: true, origin: Some(McpServerOrigin::StreamableHttp( "https://docs.example".to_string(), @@ -1162,6 +1177,7 @@ fn server_metadata_preserves_tool_approval_policy() { "https://docs.example", /*apps_mcp_product_sku*/ None, ); + config.environment_id = "remote".to_string(); config.default_tools_approval_mode = Some(AppToolApproval::Prompt); config.tools.insert( "search".to_string(), @@ -1171,6 +1187,7 @@ fn server_metadata_preserves_tool_approval_policy() { ); let metadata = McpServerMetadata::from(&EffectiveMcpServer::configured(config)); + assert_eq!(metadata.environment_id, "remote"); assert_eq!(metadata.tool_approval_mode("read"), AppToolApproval::Prompt); assert_eq!( metadata.tool_approval_mode("search"), @@ -1262,6 +1279,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index a51cd7c62353..abd5a46899e6 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -22,11 +22,11 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_rmcp_client::Elicitation; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::SendElicitation; use futures::future::BoxFuture; use futures::future::FutureExt; -use rmcp::model::CreateElicitationRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::RequestId; use tokio::sync::Mutex; @@ -36,7 +36,7 @@ use tokio::sync::oneshot; pub struct ElicitationReviewRequest { pub server_name: String, pub request_id: RequestId, - pub elicitation: CreateElicitationRequestParams, + pub elicitation: Elicitation, } pub trait ElicitationReviewer: Send + Sync { @@ -172,11 +172,13 @@ impl ElicitationRequestManager { } let request = match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - } => ElicitationRequest::Form { + Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + }, + ) => ElicitationRequest::Form { meta: meta .map(serde_json::to_value) .transpose() @@ -185,12 +187,14 @@ impl ElicitationRequestManager { requested_schema: serde_json::to_value(requested_schema) .context("failed to serialize MCP elicitation schema")?, }, - CreateElicitationRequestParams::UrlElicitationParams { - meta, - message, - url, - elicitation_id, - } => ElicitationRequest::Url { + Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + }, + ) => ElicitationRequest::Url { meta: meta .map(serde_json::to_value) .transpose() @@ -199,6 +203,15 @@ impl ElicitationRequestManager { url, elicitation_id, }, + Elicitation::OpenAiForm { + meta, + message, + requested_schema, + } => ElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + }, }; let (tx, rx) = oneshot::channel(); { @@ -243,14 +256,18 @@ pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; -fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool { +fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool { match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - requested_schema, .. - } => { + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + requested_schema, + .. + }) => { // Auto-accept confirm/approval elicitations without schema requirements. requested_schema.properties.is_empty() } - CreateElicitationRequestParams::UrlElicitationParams { .. } => false, + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + .. + }) + | Elicitation::OpenAiForm { .. } => false, } } diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 7a585cb5d828..4076880c1d38 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -305,6 +305,7 @@ pub async fn read_mcp_resource( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + /*supports_openai_form_elicitation*/ false, tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, @@ -379,6 +380,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + /*supports_openai_form_elicitation*/ false, tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, @@ -617,14 +619,16 @@ async fn collect_mcp_server_status_snapshot_from_manager( mcp_connection_manager.list_all_tools(), async { if detail.include_resources() { - mcp_connection_manager.list_all_resources().await + mcp_connection_manager.list_all_resources(|_| true).await } else { HashMap::new() } }, async { if detail.include_resources() { - mcp_connection_manager.list_all_resource_templates().await + mcp_connection_manager + .list_all_resource_templates(|_| true) + .await } else { HashMap::new() } diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 3ba1adcc94f7..d6c2590ba400 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -7,6 +7,7 @@ //! [`crate::connection_manager`]. use std::borrow::Cow; +use std::collections::BTreeMap; use std::collections::HashMap; use std::env; use std::ffi::OsString; @@ -18,7 +19,6 @@ use std::time::Instant; use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; -use crate::codex_apps::filter_disallowed_codex_apps_tools; use crate::codex_apps::load_cached_codex_apps_tools; use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; @@ -62,6 +62,7 @@ use rmcp::model::ClientCapabilities; use rmcp::model::ElicitationCapability; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; +use rmcp::model::JsonObject; use rmcp::model::ProtocolVersion; use rmcp::model::Tool as RmcpTool; use tokio_util::sync::CancellationToken; @@ -70,6 +71,7 @@ use tracing::warn; /// MCP server capability indicating that Codex should include [`SandboxState`] /// in tool-call request `_meta` under this key. pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; +pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = @@ -151,6 +153,7 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Self { let tool_filter = server .configured_config() @@ -204,6 +207,7 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + supports_openai_form_elicitation, }, ) .await @@ -401,9 +405,6 @@ pub(crate) async fn list_tools_for_client_uncached( } }) .collect(); - if server_name == CODEX_APPS_MCP_SERVER_NAME { - return Ok(filter_disallowed_codex_apps_tools(tools)); - } Ok(tools) } @@ -483,14 +484,12 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + supports_openai_form_elicitation, } = params; - let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(client_elicitation_capability); - let params = InitializeRequestParams::new( - capabilities, - Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), - ) - .with_protocol_version(ProtocolVersion::V_2025_06_18); + let params = mcp_initialize_request_params( + client_elicitation_capability, + supports_openai_form_elicitation, + ); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -550,6 +549,25 @@ async fn start_server_task( Ok(managed) } +fn mcp_initialize_request_params( + client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, +) -> InitializeRequestParams { + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + if supports_openai_form_elicitation { + capabilities.extensions = Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::new(), + )])); + } + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) +} + fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { McpServerInfo { name: server_info.name, @@ -574,6 +592,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, } async fn make_rmcp_client( @@ -668,6 +687,27 @@ mod tests { use rmcp::model::JsonObject; use rmcp::model::Meta; + #[test] + fn mcp_initialize_advertises_openai_form_only_when_supported() { + let unsupported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, + ); + assert_eq!(unsupported.capabilities.extensions, None); + + let supported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ true, + ); + assert_eq!( + supported.capabilities.extensions, + Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::new(), + )])) + ); + } + fn tool_with_connector_meta() -> RmcpTool { RmcpTool::new( "capture_file_upload", diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 5404bf1eb533..ff5c65db005c 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -12,7 +12,7 @@ use std::time::Duration; use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -22,9 +22,8 @@ use serde::Serialize; pub struct SandboxState { #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_profile: Option, - pub sandbox_policy: SandboxPolicy, pub codex_linux_sandbox_exe: Option, - pub sandbox_cwd: PathBuf, + pub sandbox_cwd: PathUri, #[serde(default)] pub use_legacy_landlock: bool, } diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index 90952cbfaf95..39287b21361d 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -75,6 +75,7 @@ impl McpServerOrigin { /// Semantic metadata that must survive after the server is launched. #[derive(Debug, Clone)] pub(crate) struct McpServerMetadata { + pub environment_id: String, pub pollutes_memory: bool, pub origin: Option, pub supports_parallel_tool_calls: bool, @@ -96,6 +97,7 @@ impl From<&EffectiveMcpServer> for McpServerMetadata { fn from(server: &EffectiveMcpServer) -> Self { match server.launch() { McpServerLaunch::Configured(config) => Self { + environment_id: config.environment_id.clone(), pollutes_memory: true, origin: McpServerOrigin::from_transport(&config.transport), supports_parallel_tool_calls: config.supports_parallel_tool_calls, diff --git a/codex-rs/collaboration-mode-templates/templates/plan.md b/codex-rs/collaboration-mode-templates/templates/plan.md index 8a1a934f83bb..ca68f41c897d 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/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index ca4407ce43cd..ac5173453686 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -665,10 +665,11 @@ fn is_glob_metacharacter(ch: char) -> bool { } #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum WebSearchModeRequirement { Disabled, Cached, + Indexed, Live, } @@ -677,6 +678,7 @@ impl From for WebSearchModeRequirement { match mode { WebSearchMode::Disabled => WebSearchModeRequirement::Disabled, WebSearchMode::Cached => WebSearchModeRequirement::Cached, + WebSearchMode::Indexed => WebSearchModeRequirement::Indexed, WebSearchMode::Live => WebSearchModeRequirement::Live, } } @@ -687,6 +689,7 @@ impl From for WebSearchMode { match mode { WebSearchModeRequirement::Disabled => WebSearchMode::Disabled, WebSearchModeRequirement::Cached => WebSearchMode::Cached, + WebSearchModeRequirement::Indexed => WebSearchMode::Indexed, WebSearchModeRequirement::Live => WebSearchMode::Live, } } @@ -697,6 +700,7 @@ impl fmt::Display for WebSearchModeRequirement { match self { WebSearchModeRequirement::Disabled => write!(f, "disabled"), WebSearchModeRequirement::Cached => write!(f, "cached"), + WebSearchModeRequirement::Indexed => write!(f, "indexed"), WebSearchModeRequirement::Live => write!(f, "live"), } } @@ -1345,6 +1349,8 @@ impl TryFrom for ConfigRequirements { let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) { WebSearchMode::Cached + } else if accepted.contains(&WebSearchModeRequirement::Indexed) { + WebSearchMode::Indexed } else if accepted.contains(&WebSearchModeRequirement::Live) { WebSearchMode::Live } else { @@ -2927,6 +2933,34 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn allowed_web_search_modes_supports_indexed() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" + allowed_web_search_modes = ["indexed"] + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Indexed); + for mode in [WebSearchMode::Disabled, WebSearchMode::Indexed] { + assert!(requirements.web_search_mode.can_set(&mode).is_ok()); + } + for mode in [WebSearchMode::Cached, WebSearchMode::Live] { + assert_eq!( + requirements.web_search_mode.can_set(&mode), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{mode:?}"), + allowed: "[Disabled, Indexed]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + } + + Ok(()) + } + #[test] fn allowed_web_search_modes_allows_disabled() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 93c0803b58dc..723449682165 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -146,6 +146,21 @@ of strings; comma-separated strings are not supported. Use \ } } +/// Orchestrator-owned feature settings. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OrchestratorToml { + pub skills: Option, + pub mcp: Option, +} + +/// Settings for a feature owned by the orchestrator. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OrchestratorFeatureToml { + pub enabled: Option, +} + /// Base config deserialized from ~/.codex/config.toml. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -389,6 +404,9 @@ pub struct ConfigToml { /// Optional product SKU forwarded on host-owned Codex Apps MCP requests. pub apps_mcp_product_sku: Option, + /// Orchestrator-owned feature settings. + pub orchestrator: Option, + /// Base URL override for the built-in `openai` model provider. pub openai_base_url: Option, @@ -438,7 +456,7 @@ pub struct ConfigToml { pub experimental_thread_store: Option, pub projects: Option>, - /// Controls the web search tool mode: disabled, cached, or live. + /// Controls the web search tool mode: disabled, cached, indexed, or live. pub web_search: Option, /// Nested tools section for feature toggles @@ -948,14 +966,12 @@ pub enum RealtimeTransport { Websocket, } -pub use codex_protocol::protocol::RealtimeConversationArchitecture as RealtimeArchitecture; pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; pub use codex_protocol::protocol::RealtimeVoice; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct RealtimeConfig { - pub architecture: RealtimeArchitecture, pub version: RealtimeWsVersion, #[serde(rename = "type")] pub session_type: RealtimeWsMode, @@ -966,7 +982,6 @@ pub struct RealtimeConfig { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct RealtimeToml { - pub architecture: Option, pub version: Option, #[serde(rename = "type")] pub session_type: Option, diff --git a/codex-rs/config/src/host_name.rs b/codex-rs/config/src/host_name.rs index dcd34b0ba384..eb67b7b8d024 100644 --- a/codex-rs/config/src/host_name.rs +++ b/codex-rs/config/src/host_name.rs @@ -10,6 +10,8 @@ use winapi_util::sysinfo::get_computer_name; static HOST_NAME: LazyLock> = LazyLock::new(compute_host_name); +/// Returns a process-cached canonical hostname, falling back to the normalized +/// kernel hostname. The first call on Unix may perform blocking DNS resolution. pub fn host_name() -> Option { HOST_NAME.clone() } diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 6d496bc9c56c..e3c7ee11285d 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -945,6 +945,11 @@ fn sanitize_project_config(config: &mut TomlValue) -> Vec { ignored_keys.push((*key).to_string()); } } + if let Some(features) = table.get_mut("features").and_then(TomlValue::as_table_mut) + && features.remove("respect_system_proxy").is_some() + { + ignored_keys.push("features.respect_system_proxy".to_string()); + } ignored_keys } diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 543c307c3548..068e29aa6ec9 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -3,6 +3,7 @@ use codex_file_system::CopyOptions; use codex_file_system::CreateDirectoryOptions; use codex_file_system::ExecutorFileSystemFuture; use codex_file_system::FileMetadata; +use codex_file_system::FileSystemReadStream; use codex_file_system::FileSystemSandboxContext; use codex_file_system::ReadDirectoryEntry; use codex_file_system::RemoveOptions; @@ -36,6 +37,19 @@ impl ExecutorFileSystem for TestFileSystem { }) } + fn read_file_stream<'a>( + &'a self, + _path: &'a PathUri, + _sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(async { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "test filesystem does not support streaming reads", + )) + }) + } + fn write_file<'a>( &'a self, _path: &'a PathUri, diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index d92024e11708..7329a66f62a7 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -54,7 +54,7 @@ pub(super) struct ComposableRequirementsLayer { impl ComposableRequirementsLayer { pub(super) fn from_entry( layer: RequirementsLayerEntry, - hostname: Option<&str>, + hostname_resolver: &dyn Fn() -> Option, ) -> Result { let RequirementsLayerEntry { source, @@ -70,7 +70,13 @@ impl ComposableRequirementsLayer { (regular_toml, requirements) }; - requirements.apply_remote_sandbox_config(hostname); + // Hostname lookup is configuration-driven and may block on DNS, so only + // resolve it when this layer contains hostname-based sandbox selectors. + let hostname = requirements + .remote_sandbox_config + .as_ref() + .and_then(|_| hostname_resolver()); + requirements.apply_remote_sandbox_config(hostname.as_deref()); materialize_remote_sandbox_config(&mut regular_toml, &requirements)?; strip_special_fields(&mut regular_toml); diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index a7bcab279f95..a7d8245ece06 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -18,6 +18,7 @@ use crate::ConfigRequirementsWithSources; use crate::RequirementSource; use crate::Sourced; use crate::merge::merge_toml_values; +use std::cell::OnceCell; use std::io; use thiserror::Error; use toml::Value as TomlValue; @@ -57,29 +58,59 @@ impl From for io::Error { pub fn compose_requirements( layers: impl IntoIterator, ) -> Result, RequirementsCompositionError> { - let hostname = crate::host_name(); - compose_requirements_for_hostname(layers, hostname.as_deref()) + compose_requirements_with_hostname_resolver(layers, crate::host_name) } +#[cfg(test)] pub(super) fn compose_requirements_for_hostname( layers: impl IntoIterator, hostname: Option<&str>, ) -> Result, RequirementsCompositionError> { - compose_requirements_for_hostname_and_hook_directory( + let hostname = hostname.map(str::to_string); + compose_requirements_with_hostname_resolver_and_hook_directory( layers, - hostname, + move || hostname.clone(), HookDirectoryField::current_platform(), ) } +#[cfg(test)] pub(super) fn compose_requirements_for_hostname_and_hook_directory( layers: impl IntoIterator, hostname: Option<&str>, hook_directory_field: HookDirectoryField, ) -> Result, RequirementsCompositionError> { + let hostname = hostname.map(str::to_string); + compose_requirements_with_hostname_resolver_and_hook_directory( + layers, + move || hostname.clone(), + hook_directory_field, + ) +} + +fn compose_requirements_with_hostname_resolver( + layers: impl IntoIterator, + hostname_resolver: impl Fn() -> Option, +) -> Result, RequirementsCompositionError> { + compose_requirements_with_hostname_resolver_and_hook_directory( + layers, + hostname_resolver, + HookDirectoryField::current_platform(), + ) +} + +fn compose_requirements_with_hostname_resolver_and_hook_directory( + layers: impl IntoIterator, + hostname_resolver: impl Fn() -> Option, + hook_directory_field: HookDirectoryField, +) -> Result, RequirementsCompositionError> { + // Evaluate every layer in this composition against the same hostname while + // keeping resolution lazy when no layer needs remote sandbox matching. + let hostname = OnceCell::new(); + let cached_hostname_resolver = || hostname.get_or_init(&hostname_resolver).clone(); let mut stack = RequirementsLayerStack::new(hook_directory_field); for layer in layers { - stack.add_layer(layer, hostname)?; + stack.add_layer(layer, &cached_hostname_resolver)?; } stack.compose() } @@ -100,10 +131,12 @@ impl RequirementsLayerStack { fn add_layer( &mut self, layer: RequirementsLayerEntry, - hostname: Option<&str>, + hostname_resolver: &dyn Fn() -> Option, ) -> Result<(), RequirementsCompositionError> { - self.layers - .push(ComposableRequirementsLayer::from_entry(layer, hostname)?); + self.layers.push(ComposableRequirementsLayer::from_entry( + layer, + hostname_resolver, + )?); Ok(()) } diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 7be51e2b33cc..fafd0523e8ff 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -3,6 +3,7 @@ use super::super::hooks::HookDirectoryField; use super::RequirementsCompositionError; use super::compose_requirements_for_hostname; use super::compose_requirements_for_hostname_and_hook_directory; +use super::compose_requirements_with_hostname_resolver; use crate::ConfigRequirementsToml; use crate::ConfigRequirementsWithSources; use crate::RequirementSource; @@ -10,6 +11,7 @@ use crate::Sourced; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::cell::Cell; use std::collections::BTreeMap; fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry { @@ -552,6 +554,81 @@ allowed_sandbox_modes = ["read-only"] ); } +#[test] +fn hostname_resolver_is_not_called_without_remote_sandbox_config() { + let calls = Cell::::default(); + let composed = compose_requirements_with_hostname_resolver( + vec![layer( + "req", + "No remote selector", + r#" +allowed_sandbox_modes = ["read-only"] +"#, + )], + || { + calls.set(calls.get() + 1); + Some("build-01.example.com".to_string()) + }, + ) + .expect("compose requirements") + .expect("requirements present") + .into_toml(); + + assert_eq!(calls.get(), 0); + assert_eq!( + composed, + expected_requirements( + r#" +allowed_sandbox_modes = ["read-only"] +"# + ) + ); +} + +#[test] +fn hostname_resolver_is_called_once_for_multiple_remote_sandbox_layers() { + let calls = Cell::::default(); + let composed = compose_requirements_with_hostname_resolver( + vec![ + layer( + "req_low", + "Low", + r#" +[[remote_sandbox_config]] +hostname_patterns = ["build-*.example.com"] +allowed_sandbox_modes = ["read-only"] +"#, + ), + layer( + "req_high", + "High", + r#" +[[remote_sandbox_config]] +hostname_patterns = ["build-*.example.com"] +allowed_sandbox_modes = ["workspace-write"] +"#, + ), + ], + || { + calls.set(calls.get() + 1); + Some("build-01.example.com".to_string()) + }, + ) + .expect("compose requirements") + .expect("requirements present") + .into_toml(); + + assert_eq!(calls.get(), 1); + assert_eq!( + composed, + expected_requirements( + r#" +allowed_sandbox_modes = ["workspace-write"] +"# + ) + ); +} + #[test] fn rules_are_appended_in_priority_order() { let composed = compose(vec![ diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index c641f1703da5..8547269ce2f4 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -44,6 +44,33 @@ 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(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::CurrentTimeReminder { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::AppsMcpPathOverride { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 40e8a7526ce7..948d2d654b3b 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -435,6 +435,10 @@ pub struct AppsDefaultConfig { skip_serializing_if = "std::clone::Clone::clone" )] pub open_world_enabled: bool, + + /// Approval mode for tools unless overridden by per-app or per-tool settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, } /// Per-tool settings for a single app tool. diff --git a/codex-rs/connectors/src/app_tool_policy.rs b/codex-rs/connectors/src/app_tool_policy.rs index 5f1e512beccd..fbc53693bf35 100644 --- a/codex-rs/connectors/src/app_tool_policy.rs +++ b/codex-rs/connectors/src/app_tool_policy.rs @@ -162,6 +162,12 @@ fn app_tool_policy_from_apps_config( let approval = managed_approval .or_else(|| tool_config.and_then(|tool| tool.approval_mode)) .or_else(|| app.and_then(|app| app.default_tools_approval_mode)) + .or_else(|| { + input + .connector_id + .and(apps_config.default.as_ref()) + .and_then(|defaults| defaults.default_tools_approval_mode) + }) .unwrap_or(AppToolApproval::Auto); if !app_is_enabled(apps_config, input.connector_id) { diff --git a/codex-rs/connectors/src/app_tool_policy_tests.rs b/codex-rs/connectors/src/app_tool_policy_tests.rs index 45e01cabdf6f..6338e6c86828 100644 --- a/codex-rs/connectors/src/app_tool_policy_tests.rs +++ b/codex-rs/connectors/src/app_tool_policy_tests.rs @@ -521,9 +521,59 @@ fn default_tools_enable_overrides_app_level_hints() { } #[test] -fn evaluator_uses_default_tools_approval_mode() { +fn evaluator_uses_apps_default_tools_approval_mode_only_with_connector_id() { let apps_config = AppsConfigToml { - default: None, + default: Some(AppsDefaultConfig { + default_tools_approval_mode: Some(AppToolApproval::Prompt), + ..defaults( + /*enabled*/ true, /*destructive_enabled*/ true, + /*open_world_enabled*/ true, + ) + }), + apps: HashMap::new(), + }; + + assert_eq!( + [ + policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + /*tool_title*/ None, + /*destructive_hint*/ None, + /*open_world_hint*/ None, + /*managed_approval*/ None, + ), + policy_from_apps_config( + Some(&apps_config), + /*connector_id*/ None, + "events/list", + /*tool_title*/ None, + /*destructive_hint*/ None, + /*open_world_hint*/ None, + /*managed_approval*/ None, + ), + ], + [ + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Prompt, + }, + AppToolPolicy::default(), + ] + ); +} + +#[test] +fn evaluator_prefers_app_default_tools_approval_mode_over_apps_default() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + default_tools_approval_mode: Some(AppToolApproval::Approve), + ..defaults( + /*enabled*/ true, /*destructive_enabled*/ true, + /*open_world_enabled*/ true, + ) + }), apps: HashMap::from([( "calendar".to_string(), AppConfig { @@ -720,5 +770,6 @@ fn defaults( approvals_reviewer: None, destructive_enabled, open_world_enabled, + default_tools_approval_mode: None, } } diff --git a/codex-rs/connectors/src/filter.rs b/codex-rs/connectors/src/filter.rs index e26291794ff5..6d60c89ad610 100644 --- a/codex-rs/connectors/src/filter.rs +++ b/codex-rs/connectors/src/filter.rs @@ -6,7 +6,6 @@ pub fn filter_tool_suggest_discoverable_connectors( directory_connectors: Vec, accessible_connectors: &[AppInfo], discoverable_connector_ids: &HashSet, - originator_value: &str, ) -> Vec { let accessible_connector_ids: HashSet<&str> = accessible_connectors .iter() @@ -14,7 +13,7 @@ pub fn filter_tool_suggest_discoverable_connectors( .map(|connector| connector.id.as_str()) .collect(); - let mut connectors = filter_disallowed_connectors(directory_connectors, originator_value) + let mut connectors = directory_connectors .into_iter() .filter(|connector| !accessible_connector_ids.contains(connector.id.as_str())) .filter(|connector| discoverable_connector_ids.contains(connector.id.as_str())) @@ -27,44 +26,6 @@ pub fn filter_tool_suggest_discoverable_connectors( connectors } -const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ - "asdk_app_6938a94a61d881918ef32cb999ff937c", - "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - "connector_3f8d1a79f27c4c7ba1a897ab13bf37dc", - "connector_68de829bf7648191acd70a907364c67c", - "connector_68e004f14af881919eb50893d3d9f523", - "connector_69272cb413a081919685ec3c88d1744e", -]; -const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = - &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; - -pub fn filter_disallowed_connectors( - connectors: Vec, - originator_value: &str, -) -> Vec { - let first_party_chat_originator = is_first_party_chat_originator(originator_value); - connectors - .into_iter() - .filter(|connector| { - is_connector_id_allowed(connector.id.as_str(), first_party_chat_originator) - }) - .collect() -} - -fn is_first_party_chat_originator(originator_value: &str) -> bool { - originator_value == "codex_atlas" || originator_value == "codex_chatgpt_desktop" -} - -fn is_connector_id_allowed(connector_id: &str, first_party_chat_originator: bool) -> bool { - let disallowed_connector_ids = if first_party_chat_originator { - FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS - } else { - DISALLOWED_CONNECTOR_IDS - }; - - !disallowed_connector_ids.contains(&connector_id) -} - #[cfg(test)] mod tests { use super::*; @@ -98,65 +59,6 @@ mod tests { } } - #[test] - fn filter_disallowed_connectors_allows_non_disallowed_connectors() { - let filtered = - filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")], "codex_cli"); - assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); - } - - #[test] - fn filter_disallowed_connectors_allows_openai_prefix() { - let filtered = filter_disallowed_connectors( - vec![ - app("connector_openai_foo"), - app("connector_openai_bar"), - app("gamma"), - ], - "codex_cli", - ); - assert_eq!( - filtered, - vec![ - app("connector_openai_foo"), - app("connector_openai_bar"), - app("gamma") - ] - ); - } - - #[test] - fn filter_disallowed_connectors_filters_disallowed_connector_ids() { - let filtered = filter_disallowed_connectors( - vec![ - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"), - app("delta"), - ], - "codex_cli", - ); - assert_eq!(filtered, vec![app("delta")]); - } - - #[test] - fn first_party_chat_originator_filters_target_connector_ids() { - let filtered = filter_disallowed_connectors( - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), - ], - "codex_atlas", - ); - assert_eq!( - filtered, - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c") - ] - ); - } - #[test] fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { let filtered = filter_tool_suggest_discoverable_connectors( @@ -179,7 +81,6 @@ mod tests { "connector_2128aebfecb84f64a069897515042a44".to_string(), "connector_68df038e0ba48191908c8434991bbac2".to_string(), ]), - "codex_cli", ); assert_eq!( @@ -219,7 +120,6 @@ mod tests { "connector_2128aebfecb84f64a069897515042a44".to_string(), "connector_68df038e0ba48191908c8434991bbac2".to_string(), ]), - "codex_cli", ); assert_eq!(filtered, Vec::::new()); diff --git a/codex-rs/context-fragments/src/fragment.rs b/codex-rs/context-fragments/src/fragment.rs index 38714e251244..5f44e3353011 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-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 7e26680be65c..e64365936f2e 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -47,9 +47,15 @@ pub use codex_core::config::ThreadStoreConfig; pub use codex_core::config::find_codex_home; pub use codex_core::init_state_db; pub use codex_core::resolve_installation_id; -pub use codex_core::skills::SkillsManager; +pub use codex_core::skills::SkillsService; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentRegistryConnectRequest; +pub use codex_exec_server::EnvironmentRegistryConnectResponse; +pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationRequest; +pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationResponse; +pub use codex_exec_server::EnvironmentRegistryRegistrationRequest; +pub use codex_exec_server::EnvironmentRegistryRegistrationResponse; pub use codex_exec_server::ExecServerError; pub use codex_exec_server::ExecServerRuntimePaths; pub use codex_exec_server::NoiseChannelIdentity; diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 1d361559aa84..b90192bfe960 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -27,6 +27,7 @@ codex-model-provider = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index c6a7146a8655..eb1df6ff523a 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -1,8 +1,8 @@ use anyhow::Context; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; +use codex_core_skills::config_rules::skill_config_rules_from_stack; use codex_login::CodexAuth; -use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use std::collections::HashSet; use tracing::warn; @@ -76,17 +76,24 @@ impl PluginsManager { return Ok(Vec::new()); } + let use_remote_global_catalog = + input.plugins.remote_plugin_enabled && auth.is_some_and(CodexAuth::uses_codex_backend); let marketplaces = self - .list_marketplaces_for_config(&input.plugins, &[], /*include_openai_curated*/ true) + .list_marketplaces_for_config( + &input.plugins, + &[], + /*include_openai_curated*/ !use_remote_global_catalog, + ) .context("failed to list plugin marketplaces for tool suggestions")? .marketplaces; - let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled { + let remote_installed_marketplaces = if use_remote_global_catalog { self.build_remote_installed_plugin_marketplaces_from_cache(&[ REMOTE_GLOBAL_MARKETPLACE_NAME, ]) } else { None }; + let skill_config_rules = skill_config_rules_from_stack(&input.plugins.config_layer_stack); let mut discoverable_plugins = Vec::::new(); for marketplace in marketplaces { @@ -104,17 +111,15 @@ impl PluginsManager { } let plugin_id = plugin.id.clone(); - match self - .read_plugin_detail_for_marketplace_plugin( - &input.plugins, + .tool_suggest_metadata_for_marketplace_plugin( &marketplace_name, - plugin, + &plugin, + &skill_config_rules, ) .await { Ok(plugin) => { - let plugin: PluginCapabilitySummary = plugin.into(); discoverable_plugins.push(ToolSuggestDiscoverablePlugin { id: plugin.config_name, remote_plugin_id: None, diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index c1fb04f92cc0..d472c76344c4 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -15,6 +15,7 @@ use crate::test_support::write_curated_plugin_sha_with; use crate::test_support::write_file; use crate::test_support::write_openai_api_curated_marketplace; use crate::test_support::write_openai_curated_marketplace; +use codex_app_server_protocol::AuthMode; use codex_config::CONFIG_TOML_FILE; use codex_login::CodexAuth; use codex_utils_absolute_path::AbsolutePathBuf; @@ -34,17 +35,19 @@ use wiremock::matchers::path; use wiremock::matchers::query_param; #[tokio::test] -async fn returns_fallback_plugins_without_installed_apps() { +async fn returns_fallback_plugins_when_remote_disabled_for_codex_auth() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + plugins_manager.set_auth_mode(Some(AuthMode::Chatgpt)); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, discovery_input(plugins, &[], &[], &[]), - /*auth*/ None, + Some(&auth), ) .await; @@ -66,8 +69,10 @@ async fn returns_api_curated_fallback_plugins_for_direct_provider_auth() { let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_api_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); - let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + plugins.remote_plugin_enabled = true; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + plugins_manager.set_auth_mode(Some(AuthMode::ApiKey)); let auth = CodexAuth::from_api_key("test-api-key"); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, @@ -121,7 +126,7 @@ async fn returns_microsoft_fallback_plugins() { } #[tokio::test] -async fn includes_openai_curated_when_remote_enabled() { +async fn omits_openai_curated_but_keeps_configured_marketplaces_for_remote_codex_auth() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); @@ -159,6 +164,33 @@ source = "/tmp/{bundled_marketplace_name}" let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + plugins_manager.set_auth_mode(Some(AuthMode::Chatgpt)); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + Some(&auth), + ) + .await; + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["chrome@openai-bundled".to_string()] + ); +} + +#[tokio::test] +async fn includes_openai_curated_when_remote_enabled_without_auth() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + plugins.remote_plugin_enabled = true; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, discovery_input(plugins, &[], &[], &[]), @@ -171,15 +203,12 @@ source = "/tmp/{bundled_marketplace_name}" .into_iter() .map(|plugin| plugin.id) .collect::>(), - vec![ - "chrome@openai-bundled".to_string(), - "slack@openai-curated".to_string(), - ] + vec!["slack@openai-curated".to_string()] ); } #[tokio::test] -async fn deduplicates_configured_marketplace_plugin() { +async fn deduplicates_and_reprojects_cached_configured_marketplace_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); let plugin_name = "sample"; let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; @@ -200,6 +229,12 @@ async fn deduplicates_configured_marketplace_plugin() { ), ); write_curated_plugin(&marketplace_root, plugin_name); + write_plugin_app( + &marketplace_root, + plugin_name, + "sample-docs", + "connector_sample", + ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), &format!( @@ -212,18 +247,179 @@ source = "/tmp/{marketplace_name}" "# ), ); + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + assert!(plugins_manager.set_auth_mode(Some(AuthMode::Chatgpt))); + let chatgpt_projection = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins.clone(), &[plugin_id.as_str()], &[], &[]), + /*auth*/ None, + ) + .await; + let expected = ToolSuggestDiscoverablePlugin { + id: plugin_id.clone(), + remote_plugin_id: None, + name: "sample".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: vec!["connector_sample".to_string()], + }; + assert_eq!(chatgpt_projection, vec![expected.clone()]); + + assert!(plugins_manager.set_auth_mode(Some(AuthMode::ApiKey))); + let api_key_projection = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[plugin_id.as_str()], &[], &[]), + /*auth*/ None, + ) + .await; + assert_eq!( + api_key_projection, + vec![ToolSuggestDiscoverablePlugin { + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: Vec::new(), + ..expected + }] + ); +} + +#[tokio::test] +async fn reprojects_cached_skill_availability_for_current_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let expected = ToolSuggestDiscoverablePlugin { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "slack".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }; + let initial = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + assert_eq!(initial, vec![expected.clone()]); + + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[[skills.config]] +name = "slack:sample" +enabled = false +"#, + ); + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let after_skill_disabled = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + assert_eq!( + after_skill_disabled, + vec![ToolSuggestDiscoverablePlugin { + has_skills: false, + ..expected + }] + ); +} + +#[tokio::test] +async fn does_not_advertise_skills_when_skill_loading_fails() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_file( + &curated_root.join("plugins/slack/skills/SKILL.md"), + "---\nname: bad", + ); let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); let discoverable_plugins = list_discoverable_plugins( &plugins_manager, - discovery_input(plugins, &[plugin_id.as_str()], &[], &[]), + discovery_input(plugins, &[], &[], &[]), /*auth*/ None, ) .await; - assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, plugin_id); + assert_eq!( + discoverable_plugins, + vec![ToolSuggestDiscoverablePlugin { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "slack".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: false, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }] + ); +} + +#[tokio::test] +async fn clear_cache_invalidates_cached_tool_suggest_metadata() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + let plugin_manifest = curated_root.join("plugins/slack/.codex-plugin/plugin.json"); + write_file( + &plugin_manifest, + r#"{ + "name": "slack", + "description": "Before reload" +}"#, + ); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let input = discovery_input(plugins, &[], &[], &[]); + let expected_cached = vec![ToolSuggestDiscoverablePlugin { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "slack".to_string(), + description: Some("Before reload".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }]; + let initial = list_discoverable_plugins(&plugins_manager, input.clone(), /*auth*/ None).await; + assert_eq!(initial, expected_cached); + + write_file( + &plugin_manifest, + r#"{ + "name": "slack", + "description": "After reload" +}"#, + ); + let before_reload = + list_discoverable_plugins(&plugins_manager, input.clone(), /*auth*/ None).await; + assert_eq!(before_reload, expected_cached); + + plugins_manager.clear_cache(); + let after_reload = list_discoverable_plugins(&plugins_manager, input, /*auth*/ None).await; + assert_eq!( + after_reload, + vec![ToolSuggestDiscoverablePlugin { + description: Some("After reload".to_string()), + ..expected_cached[0].clone() + }] + ); } #[tokio::test] diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 56775d07626e..08e77606fcbc 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -18,6 +18,7 @@ pub mod store; #[cfg(test)] mod test_support; pub mod toggles; +mod tool_suggest_metadata; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; pub const OPENAI_API_CURATED_MARKETPLACE_NAME: &str = "openai-api-curated"; @@ -49,8 +50,11 @@ pub use manager::PluginReadRequest; pub use manager::PluginUninstallError; pub use manager::PluginsConfigInput; pub use manager::PluginsManager; +pub use manager::RecommendedPluginCandidatesInput; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; pub use provider::ExecutorPluginProvider; pub use provider::ExecutorPluginProviderError; pub use provider::ResolvedExecutorPlugin; +pub use remote::RecommendedPlugin; +pub use remote::RecommendedPluginsMode; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 9abe4ac7f163..2a8d44fc9702 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1,22 +1,27 @@ use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::app_mcp_routing::apps_route_available; use crate::is_openai_curated_marketplace_name; +use crate::manifest::PluginManifest; use crate::manifest::PluginManifestHooks; +use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; +use crate::marketplace::find_marketplace_plugin; use crate::marketplace::list_marketplaces; use crate::marketplace::load_marketplace; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; use crate::store::PluginStore; use crate::store::plugin_version_for_source; +use crate::store::plugin_version_for_source_with_fallback_manifest; use codex_app_server_protocol::AuthMode; use codex_config::ConfigLayerStack; use codex_config::HooksFile; use codex_config::types::McpServerConfig; use codex_config::types::PluginConfig; use codex_config::types::PluginMcpServerConfig; +use codex_core_skills::PluginSkillSnapshots; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::resolve_disabled_skill_paths; @@ -33,8 +38,6 @@ use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_plugin::PluginIdError; -use codex_plugin::PluginLoadOutcome; -use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; @@ -71,6 +74,7 @@ enum PluginLoadScope<'a> { AllCapabilities { restriction_product: Option, skill_config_rules: &'a SkillConfigRules, + plugin_skill_snapshots: Option<&'a PluginSkillSnapshots>, }, HooksOnly, } @@ -81,12 +85,8 @@ enum NonCuratedCacheRefreshMode { ForceReinstall, } -pub fn log_plugin_load_errors(outcome: &PluginLoadOutcome) { - for plugin in outcome - .plugins() - .iter() - .filter(|plugin| plugin.error.is_some()) - { +pub(crate) fn log_plugin_load_errors(plugins: &[LoadedPlugin]) { + for plugin in plugins.iter().filter(|plugin| plugin.error.is_some()) { if let Some(error) = plugin.error.as_deref() { warn!( plugin = plugin.config_name, @@ -110,14 +110,16 @@ struct PluginAppConfig { category: Option, } +/// Load configured plugins without applying auth-dependent runtime policies. #[instrument(level = "trace", skip_all)] -pub async fn load_plugins_from_layer_stack( +pub(crate) async fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, extra_plugins: HashMap, store: &PluginStore, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, restriction_product: Option, prefer_remote_curated_conflicts: bool, -) -> PluginLoadOutcome { +) -> Vec> { let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); load_plugins_from_layer_stack_with_scope( config_layer_stack, @@ -127,6 +129,7 @@ pub async fn load_plugins_from_layer_stack( PluginLoadScope::AllCapabilities { restriction_product, skill_config_rules: &skill_config_rules, + plugin_skill_snapshots, }, ) .await @@ -138,7 +141,7 @@ async fn load_plugins_from_layer_stack_with_scope( store: &PluginStore, prefer_remote_curated_conflicts: bool, scope: PluginLoadScope<'_>, -) -> PluginLoadOutcome { +) -> Vec> { let configured_plugins = merge_configured_plugins_with_remote_installed( configured_plugins_from_stack(config_layer_stack), extra_plugins, @@ -167,7 +170,7 @@ async fn load_plugins_from_layer_stack_with_scope( plugins.push(loaded_plugin); } - PluginLoadOutcome::from_plugins(plugins) + plugins } /// Load hooks from enabled plugins without loading their skills, MCP servers, or apps. @@ -177,7 +180,7 @@ pub async fn load_plugin_hooks_from_layer_stack( store: &PluginStore, prefer_remote_curated_conflicts: bool, ) -> PluginHookLoadOutcome { - let outcome = load_plugins_from_layer_stack_with_scope( + let plugins = load_plugins_from_layer_stack_with_scope( config_layer_stack, extra_plugins, store, @@ -186,8 +189,16 @@ pub async fn load_plugin_hooks_from_layer_stack( ) .await; PluginHookLoadOutcome { - hook_sources: outcome.effective_plugin_hook_sources(), - hook_load_warnings: outcome.effective_plugin_hook_warnings(), + hook_sources: plugins + .iter() + .filter(|plugin| plugin.is_active()) + .flat_map(|plugin| plugin.hook_sources.iter().cloned()) + .collect(), + hook_load_warnings: plugins + .iter() + .filter(|plugin| plugin.is_active()) + .flat_map(|plugin| plugin.hook_load_warnings.iter().cloned()) + .collect(), } } @@ -454,7 +465,7 @@ fn refresh_non_curated_plugin_cache_with_mode( let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; let marketplace_outcome = list_marketplaces(additional_roots) .map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?; - let mut plugin_sources = HashMap::::new(); + let mut plugin_sources = HashMap::)>::new(); for marketplace in marketplace_outcome.marketplaces { if is_openai_curated_marketplace_name(&marketplace.name) { @@ -483,14 +494,31 @@ fn refresh_non_curated_plugin_cache_with_mode( continue; } - plugin_sources.insert(plugin_key, plugin.source); + let manifest_fallback = find_marketplace_plugin(&marketplace.path, &plugin.name) + .map(|resolved| { + resolved + .manifest_fallback + .contents_if_has_metadata() + .map(str::to_string) + }) + .unwrap_or_else(|err| { + warn!( + plugin = plugin.name, + marketplace = marketplace.name, + error = %err, + "failed to resolve marketplace plugin manifest fallback during cache refresh" + ); + None + }); + plugin_sources.insert(plugin_key, (plugin.source, manifest_fallback)); } } let mut cache_refreshed = false; for plugin_id in configured_non_curated_plugin_ids { let plugin_key = plugin_id.as_key(); - let Some(source) = plugin_sources.get(&plugin_key).cloned() else { + let Some((source, manifest_fallback_contents)) = plugin_sources.get(&plugin_key).cloned() + else { warn!( plugin = plugin_id.plugin_name, marketplace = plugin_id.marketplace_name, @@ -503,8 +531,14 @@ fn refresh_non_curated_plugin_cache_with_mode( format!("failed to materialize plugin source for {plugin_key}: {err}") })?; let source_path = materialized.path.clone(); - let plugin_version = plugin_version_for_source(source_path.as_path()) - .map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?; + let plugin_version = match manifest_fallback_contents.as_deref() { + Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest( + source_path.as_path(), + manifest_contents, + ), + None => plugin_version_for_source(source_path.as_path()), + } + .map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?; if mode == NonCuratedCacheRefreshMode::IfVersionChanged && store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str()) @@ -512,9 +546,16 @@ fn refresh_non_curated_plugin_cache_with_mode( continue; } - store - .install_with_version(source_path, plugin_id.clone(), plugin_version) - .map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?; + match manifest_fallback_contents.as_deref() { + Some(manifest_contents) => store.install_with_version_and_fallback_manifest( + source_path, + plugin_id.clone(), + plugin_version, + manifest_contents, + ), + None => store.install_with_version(source_path, plugin_id.clone(), plugin_version), + } + .map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?; cache_refreshed = true; } @@ -659,6 +700,7 @@ async fn load_plugin( let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, + plugin_namespace: None, manifest_description: None, root, enabled: plugin.enabled, @@ -701,10 +743,12 @@ async fn load_plugin( }; let manifest_paths = &manifest.paths; + loaded_plugin.plugin_namespace = Some(manifest.name.clone()); match scope { PluginLoadScope::AllCapabilities { restriction_product, skill_config_rules, + plugin_skill_snapshots, } => { loaded_plugin.manifest_name = Some(manifest.display_name().to_string()); loaded_plugin.manifest_description = manifest.description.clone(); @@ -712,33 +756,21 @@ async fn load_plugin( let resolved_skills = load_plugin_skills( &plugin_root, &loaded_plugin_id, - manifest_paths, + &manifest, *restriction_product, skill_config_rules, + *plugin_skill_snapshots, ) .await; let has_enabled_skills = resolved_skills.has_enabled_skills(); loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; loaded_plugin.has_enabled_skills = has_enabled_skills; - let mut mcp_servers = HashMap::new(); - for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { - let plugin_mcp = - load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await; - for (name, mut config) in plugin_mcp.mcp_servers { - if let Some(policy) = plugin.mcp_servers.get(&name) { - apply_plugin_mcp_server_policy(&mut config, policy); - } - if mcp_servers.insert(name.clone(), config).is_some() { - warn!( - plugin = %plugin_root.display(), - path = %mcp_config_path.display(), - server = name, - "plugin MCP file overwrote an earlier server definition" - ); - } - } - } - loaded_plugin.mcp_servers = mcp_servers; + loaded_plugin.mcp_servers = load_plugin_mcp_servers_from_manifest( + plugin_root.as_path(), + manifest_paths, + Some(&plugin.mcp_servers), + ) + .await; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; } PluginLoadScope::HooksOnly => {} @@ -773,6 +805,29 @@ fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginM } } +pub(crate) struct PluginSkillInventory { + skills: Vec, + had_errors: bool, +} + +impl PluginSkillInventory { + pub(crate) fn has_enabled_skills(&self, skill_config_rules: &SkillConfigRules) -> bool { + contains_enabled_skill( + &self.skills, + &resolve_disabled_skill_paths(&self.skills, skill_config_rules), + ) + } + + fn resolve(self, skill_config_rules: &SkillConfigRules) -> ResolvedPluginSkills { + let disabled_skill_paths = resolve_disabled_skill_paths(&self.skills, skill_config_rules); + ResolvedPluginSkills { + skills: self.skills, + disabled_skill_paths, + had_errors: self.had_errors, + } + } +} + #[derive(Debug, Clone)] pub struct ResolvedPluginSkills { pub skills: Vec, @@ -782,55 +837,76 @@ pub struct ResolvedPluginSkills { impl ResolvedPluginSkills { pub fn has_enabled_skills(&self) -> bool { - self.had_errors - || self - .skills - .iter() - .any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md)) + self.had_errors || contains_enabled_skill(&self.skills, &self.disabled_skill_paths) } } +fn contains_enabled_skill( + skills: &[SkillMetadata], + disabled_skill_paths: &HashSet, +) -> bool { + skills + .iter() + .any(|skill| !disabled_skill_paths.contains(&skill.path_to_skills_md)) +} + pub async fn load_plugin_skills( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, - manifest_paths: &PluginManifestPaths, + manifest: &PluginManifest, restriction_product: Option, skill_config_rules: &SkillConfigRules, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, ) -> ResolvedPluginSkills { - let roots = plugin_skill_roots(plugin_root, manifest_paths) + load_plugin_skill_inventory( + plugin_root, + plugin_id, + manifest, + restriction_product, + plugin_skill_snapshots, + ) + .await + .resolve(skill_config_rules) +} + +pub(crate) async fn load_plugin_skill_inventory( + plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, + manifest: &PluginManifest, + restriction_product: Option, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, +) -> PluginSkillInventory { + let roots = plugin_skill_roots(plugin_root, &manifest.paths) .into_iter() .map(|path| SkillRoot { path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(plugin_id.as_key()), + plugin_namespace: Some(manifest.name.clone()), plugin_root: Some(plugin_root.clone()), }) .collect::>(); - let outcome = load_skills_from_roots(roots).await; + let outcome = load_skills_from_roots(roots, plugin_skill_snapshots).await; let had_errors = !outcome.errors.is_empty(); let skills = outcome .skills .into_iter() .filter(|skill| skill.matches_product_restriction_for_product(restriction_product)) .collect::>(); - let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules); - ResolvedPluginSkills { - skills, - disabled_skill_paths, - had_errors, - } + PluginSkillInventory { skills, had_errors } } fn plugin_skill_roots( plugin_root: &AbsolutePathBuf, manifest_paths: &PluginManifestPaths, ) -> Vec { - let mut paths = default_skill_roots(plugin_root); - if let Some(path) = &manifest_paths.skills { - paths.push(path.clone()); - } + let mut paths = if manifest_paths.skills.is_empty() { + default_skill_roots(plugin_root) + } else { + manifest_paths.skills.clone() + }; paths.sort_unstable(); paths.dedup(); paths @@ -849,7 +925,7 @@ fn plugin_mcp_config_paths( plugin_root: &Path, manifest_paths: &PluginManifestPaths, ) -> Vec { - if let Some(path) = &manifest_paths.mcp_servers { + if let Some(PluginManifestMcpServers::Path(path)) = &manifest_paths.mcp_servers { return vec![path.clone()]; } default_mcp_config_paths(plugin_root) @@ -870,15 +946,22 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec { pub async fn load_plugin_apps(plugin_root: &Path) -> Vec { if let Some(manifest) = load_plugin_manifest(plugin_root) { - return load_apps_from_paths( - plugin_root, - plugin_app_config_paths(plugin_root, &manifest.paths), - ) - .await; + return load_plugin_apps_from_manifest(plugin_root, &manifest.paths).await; } load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await } +pub(crate) async fn load_plugin_apps_from_manifest( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, +) -> Vec { + load_apps_from_paths( + plugin_root, + plugin_app_config_paths(plugin_root, manifest_paths), + ) + .await +} + pub fn plugin_app_declarations_from_value(value: &JsonValue) -> Vec { let Ok(parsed) = serde_json::from_value::(value.clone()) else { return Vec::new(); @@ -1080,25 +1163,22 @@ 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(); - let mut mcp_server_names = Vec::new(); - for path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { - mcp_server_names.extend( - load_mcp_servers_from_file(plugin_root.as_path(), &path) - .await - .mcp_servers - .into_keys(), - ); - } + let mut mcp_server_names = load_plugin_mcp_servers_from_manifest( + plugin_root.as_path(), + manifest_paths, + /*plugin_policy*/ None, + ) + .await + .into_keys() + .collect::>(); mcp_server_names.sort_unstable(); mcp_server_names.dedup(); @@ -1109,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( @@ -1147,35 +1223,55 @@ async fn load_declared_plugin_mcp_servers(plugin_root: &Path) -> HashMap>, +) -> HashMap { let mut mcp_servers = HashMap::new(); - for mcp_config_path in plugin_mcp_config_paths(plugin_root, &manifest.paths) { - let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await; - for (name, config) in plugin_mcp.mcp_servers { - mcp_servers.entry(name).or_insert(config); + match &manifest_paths.mcp_servers { + Some(PluginManifestMcpServers::Object(object_servers)) => { + let plugin_mcp = load_mcp_servers_from_manifest_object(plugin_root, object_servers); + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) { + apply_plugin_mcp_server_policy(&mut config, policy); + } + if mcp_servers.insert(name.clone(), config).is_some() { + warn!( + plugin = %plugin_root.display(), + server = name, + "plugin manifest MCP object overwrote an earlier server definition" + ); + } + } + } + Some(PluginManifestMcpServers::Path(_)) | None => { + for mcp_config_path in plugin_mcp_config_paths(plugin_root, manifest_paths) { + let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await; + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) { + apply_plugin_mcp_server_policy(&mut config, policy); + } + if mcp_servers.insert(name.clone(), config).is_some() { + warn!( + plugin = %plugin_root.display(), + path = %mcp_config_path.display(), + server = name, + "plugin MCP file overwrote an earlier server definition" + ); + } + } + } } } 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, @@ -1208,6 +1304,37 @@ async fn load_mcp_servers_from_file( } } +fn load_mcp_servers_from_manifest_object( + plugin_root: &Path, + object_config: &str, +) -> PluginMcpDiscovery { + let parsed = match parse_plugin_mcp_config( + plugin_root, + object_config, + PluginMcpServerPlacement::Declared, + ) { + Ok(parsed) => parsed, + Err(err) => { + warn!( + plugin = %plugin_root.display(), + "failed to parse plugin manifest MCP object: {err}" + ); + return PluginMcpDiscovery::default(); + } + }; + for error in parsed.errors { + warn!( + plugin = %plugin_root.display(), + server = error.name, + error = error.message, + "failed to parse plugin manifest MCP object server" + ); + } + PluginMcpDiscovery { + mcp_servers: parsed.servers.into_iter().collect(), + } +} + #[derive(Debug, Default)] struct PluginMcpDiscovery { mcp_servers: HashMap, diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index ddf1b66fecb4..ee22256fd1b4 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -160,6 +160,7 @@ enabled = true &stack, HashMap::new(), &store, + /*plugin_skill_snapshots*/ None, Some(Product::Codex), /*prefer_remote_curated_conflicts*/ false, ) @@ -173,9 +174,8 @@ enabled = true ) .await; - let validation_state = |outcome: &PluginLoadOutcome| { - outcome - .plugins() + let validation_state = |plugins: &[LoadedPlugin]| { + plugins .iter() .map(|plugin| { ( @@ -183,22 +183,15 @@ enabled = true plugin.enabled, plugin.root.clone(), plugin.error.clone(), + plugin.hook_sources.clone(), + plugin.hook_load_warnings.clone(), ) }) .collect::>() }; assert_eq!(validation_state(&hooks_only), validation_state(&full)); - assert_eq!( - hooks_only.effective_plugin_hook_sources(), - full.effective_plugin_hook_sources() - ); - assert_eq!( - hooks_only.effective_plugin_hook_warnings(), - full.effective_plugin_hook_warnings() - ); let full_valid = full - .plugins() .iter() .find(|plugin| plugin.config_name == "valid@test") .expect("full load should include valid plugin"); @@ -208,7 +201,6 @@ enabled = true assert!(!full_valid.apps.is_empty()); let hooks_only_valid = hooks_only - .plugins() .iter() .find(|plugin| plugin.config_name == "valid@test") .expect("hooks-only load should include valid plugin"); diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 82cd1dc63d8e..19b48b2c017e 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1,3 +1,4 @@ +use super::LoadedPlugin; use super::PluginLoadOutcome; use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; @@ -5,16 +6,15 @@ 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; +use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_hooks_from_layer_stack; -use crate::loader::load_plugin_mcp_servers; +use crate::loader::load_plugin_mcp_servers_from_manifest; 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; @@ -26,6 +26,7 @@ use crate::marketplace::MarketplaceInterface; use crate::marketplace::MarketplaceListError; use crate::marketplace::MarketplaceListOutcome; use crate::marketplace::MarketplacePluginAuthPolicy; +use crate::marketplace::MarketplacePluginManifestFallback; use crate::marketplace::MarketplacePluginPolicy; use crate::marketplace::MarketplacePluginSource; use crate::marketplace::ResolvedMarketplacePlugin; @@ -37,6 +38,8 @@ use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; use crate::marketplace_upgrade::configured_git_marketplace_names; use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::remote::RecommendedPluginsMode; use crate::remote::RemoteInstalledPlugin; use crate::remote::RemotePluginCatalogError; use crate::remote::RemotePluginServiceConfig; @@ -49,12 +52,16 @@ use crate::startup_sync::sync_openai_plugins_repo; use crate::store::PluginInstallResult as StorePluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; +use crate::tool_suggest_metadata::ToolSuggestMetadataCache; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AuthMode; use codex_config::ConfigLayerStack; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; +use codex_config::types::ToolSuggestDisabledTool; +use codex_config::types::ToolSuggestDiscoverableType; +use codex_core_skills::PluginSkillSnapshots; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::skill_config_rules_from_stack; @@ -65,10 +72,14 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginIdError; +use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; +use codex_tools::DiscoverablePluginInfo; +use codex_tools::DiscoverableTool; +use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginSkillRoot; use std::collections::HashMap; @@ -79,6 +90,7 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; +use tokio::sync::OnceCell; use tokio::sync::Semaphore; use tracing::instrument; use tracing::warn; @@ -111,6 +123,15 @@ impl PluginsConfigInput { } } +/// Inputs used to select endpoint-backed plugin install candidates. +pub struct RecommendedPluginCandidatesInput<'a> { + pub plugins_config: &'a PluginsConfigInput, + pub loaded_plugins: &'a PluginLoadOutcome, + pub auth: Option<&'a CodexAuth>, + pub disabled_tools: &'a [ToolSuggestDisabledTool], + pub app_server_client_name: Option<&'a str>, +} + #[derive(Clone, PartialEq, Eq)] struct FeaturedPluginIdsCacheKey { chatgpt_base_url: String, @@ -119,6 +140,11 @@ struct FeaturedPluginIdsCacheKey { is_workspace_account: bool, } +#[derive(Clone, Hash, PartialEq, Eq)] +struct RecommendedPluginsCacheKey { + chatgpt_base_url: String, +} + #[derive(Clone)] struct CachedFeaturedPluginIds { key: FeaturedPluginIdsCacheKey, @@ -208,21 +234,10 @@ fn featured_plugin_ids_cache_key( } } -fn project_plugin_load_outcome_for_auth( - outcome: PluginLoadOutcome, - auth_mode: Option, -) -> PluginLoadOutcome { - let mut plugins = outcome.plugins().to_vec(); - for plugin in &mut plugins { - let plugin_active = plugin.is_active(); - apply_app_mcp_routing_policy( - &mut plugin.apps, - &mut plugin.mcp_servers, - auth_mode, - plugin_active, - ); +fn recommended_plugins_cache_key(config: &PluginsConfigInput) -> RecommendedPluginsCacheKey { + RecommendedPluginsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), } - PluginLoadOutcome::from_plugins(plugins) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -302,6 +317,7 @@ pub struct ConfiguredMarketplacePlugin { pub policy: MarketplacePluginPolicy, pub interface: Option, pub keywords: Vec, + pub manifest_fallback: Option, pub installed: bool, pub enabled: bool, } @@ -334,10 +350,15 @@ pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, featured_plugin_ids_cache: RwLock>, + recommended_plugins_cache: RwLock>, + recommended_plugins_refreshes: + RwLock>>>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - enabled_outcome_cache: RwLock, - enabled_outcome_load_semaphore: Semaphore, + // Keep the cache auth-independent so auth changes only need to resolve capabilities again. + loaded_plugins_cache: RwLock, + loaded_plugins_load_semaphore: Semaphore, + tool_suggest_metadata_cache: ToolSuggestMetadataCache, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, global_remote_catalog_cache_refresh_state: RwLock, @@ -347,15 +368,16 @@ pub struct PluginsManager { } #[derive(Clone)] -struct CachedPluginLoadOutcome { +struct LoadedPluginsCacheEntry { key: PluginLoadCacheKey, - outcome: PluginLoadOutcome, + plugins: Vec, + plugin_skill_snapshots: PluginSkillSnapshots, } #[derive(Default)] -struct EnabledOutcomeCache { +struct LoadedPluginsCache { generation: u64, - outcome: Option, + entry: Option, } #[derive(Clone, PartialEq, Eq)] @@ -365,6 +387,16 @@ struct PluginLoadCacheKey { remote_plugin_enabled: bool, } +impl PluginLoadCacheKey { + fn from_config(config: &PluginsConfigInput) -> Self { + Self { + configured_plugins: configured_plugins_from_stack(&config.config_layer_stack), + skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), + remote_plugin_enabled: config.remote_plugin_enabled, + } + } +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_options(codex_home, Some(Product::Codex), /*auth_mode*/ None) @@ -386,12 +418,15 @@ impl PluginsManager { codex_home: codex_home.clone(), store: PluginStore::new(codex_home), featured_plugin_ids_cache: RwLock::new(None), + recommended_plugins_cache: RwLock::new(HashMap::new()), + recommended_plugins_refreshes: RwLock::new(HashMap::new()), configured_marketplace_upgrade_state: RwLock::new( ConfiguredMarketplaceUpgradeState::default(), ), non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), - enabled_outcome_cache: RwLock::new(EnabledOutcomeCache::default()), - enabled_outcome_load_semaphore: Semaphore::new(/*permits*/ 1), + loaded_plugins_cache: RwLock::new(LoadedPluginsCache::default()), + loaded_plugins_load_semaphore: Semaphore::new(/*permits*/ 1), + tool_suggest_metadata_cache: ToolSuggestMetadataCache::new(), remote_installed_plugins_cache: RwLock::new(None), remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), @@ -447,10 +482,33 @@ impl PluginsManager { .await } + /// Returns skill snapshots parsed while loading the matching plugin cache entry. + pub fn plugin_skill_snapshots_for_config( + &self, + config: &PluginsConfigInput, + ) -> Option { + if !config.plugins_enabled { + return None; + } + let key = PluginLoadCacheKey::from_config(config); + self.loaded_plugins_cache + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry + .as_ref() + .filter(|cached| cached.key == key) + .map(|cached| cached.plugin_skill_snapshots.clone()) + } + #[instrument( - level = "trace", + name = "plugins_for_config", + level = "info", skip_all, - fields(force_reload, plugins_enabled = config.plugins_enabled) + fields( + otel.name = "plugins_for_config", + force_reload, + plugins_enabled = config.plugins_enabled + ) )] pub(crate) async fn plugins_for_config_with_force_reload( &self, @@ -461,42 +519,55 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - let cache_key = PluginLoadCacheKey { - configured_plugins: configured_plugins_from_stack(&config.config_layer_stack), - skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), - remote_plugin_enabled: config.remote_plugin_enabled, - }; - if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { - return self.project_plugins_for_auth(outcome); + let cache_key = PluginLoadCacheKey::from_config(config); + if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) { + return self.resolve_loaded_plugins_for_auth(plugins); } - let Ok(_load_permit) = self.enabled_outcome_load_semaphore.acquire().await else { + let Ok(_load_permit) = self.loaded_plugins_load_semaphore.acquire().await else { warn!("plugin load semaphore closed"); return PluginLoadOutcome::default(); }; - if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { - return self.project_plugins_for_auth(outcome); + if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) { + return self.resolve_loaded_plugins_for_auth(plugins); } - let cache_generation = self.enabled_outcome_cache_generation(); - let outcome = load_plugins_from_layer_stack( + let cache_generation = self.loaded_plugins_cache_generation(); + let plugin_skill_snapshots = PluginSkillSnapshots::for_plugin_load(); + let plugins = load_plugins_from_layer_stack( &config.config_layer_stack, self.remote_installed_plugin_configs(), &self.store, + Some(&plugin_skill_snapshots), self.restriction_product, config.remote_plugin_enabled, ) .await; - log_plugin_load_errors(&outcome); - self.cache_enabled_outcome_if_current(cache_generation, cache_key, outcome.clone()); - self.project_plugins_for_auth(outcome) + log_plugin_load_errors(&plugins); + self.cache_loaded_plugins_if_current( + cache_generation, + cache_key, + plugins.clone(), + plugin_skill_snapshots, + ); + self.resolve_loaded_plugins_for_auth(plugins) } - fn project_plugins_for_auth(&self, outcome: PluginLoadOutcome) -> PluginLoadOutcome { - project_plugin_load_outcome_for_auth(outcome, self.auth_mode()) + fn resolve_loaded_plugins_for_auth(&self, mut plugins: Vec) -> PluginLoadOutcome { + let auth_mode = self.auth_mode(); + for plugin in &mut plugins { + let plugin_active = plugin.is_active(); + apply_app_mcp_routing_policy( + &mut plugin.apps, + &mut plugin.mcp_servers, + auth_mode, + plugin_active, + ); + } + PluginLoadOutcome::from_plugins(plugins) } pub fn clear_cache(&self) { - self.clear_enabled_outcome_cache(); + self.clear_loaded_plugins_cache(); let mut featured_plugin_ids_cache = match self.featured_plugin_ids_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), @@ -504,13 +575,38 @@ impl PluginsManager { *featured_plugin_ids_cache = None; } - fn clear_enabled_outcome_cache(&self) { - let mut cache = match self.enabled_outcome_cache.write() { + pub fn clear_recommended_plugins_cache(&self) { + let mut refreshes = match self.recommended_plugins_refreshes.write() { + Ok(refreshes) => refreshes, + Err(err) => err.into_inner(), + }; + refreshes.clear(); + let mut cache = match self.recommended_plugins_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache.clear(); + } + + fn clear_loaded_plugins_cache(&self) { + self.tool_suggest_metadata_cache.clear(); + let mut cache = match self.loaded_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; cache.generation = cache.generation.wrapping_add(1); - cache.outcome = None; + cache.entry = None; + } + + fn clear_caches_after_marketplace_source_refresh( + &self, + installed_plugin_cache_refreshed: bool, + ) { + if installed_plugin_cache_refreshed { + self.clear_cache(); + } else { + self.tool_suggest_metadata_cache.clear(); + } } /// Load plugins for a config layer stack without touching the plugins cache. @@ -522,15 +618,16 @@ impl PluginsManager { if !config.plugins_enabled { return PluginLoadOutcome::default(); } - let outcome = load_plugins_from_layer_stack( + let plugins = load_plugins_from_layer_stack( config_layer_stack, self.remote_installed_plugin_configs(), &self.store, + /*plugin_skill_snapshots*/ None, self.restriction_product, config.remote_plugin_enabled, ) .await; - self.project_plugins_for_auth(outcome) + self.resolve_loaded_plugins_for_auth(plugins) } /// Resolve plugin hooks for a config layer stack without loading other plugin capabilities. @@ -562,41 +659,40 @@ impl PluginsManager { .effective_plugin_skill_roots() } - fn cached_enabled_outcome(&self, key: &PluginLoadCacheKey) -> Option { - match self.enabled_outcome_cache.read() { - Ok(cache) => cache - .outcome - .as_ref() - .filter(|cached| cached.key == *key) - .map(|cached| cached.outcome.clone()), - Err(err) => err - .into_inner() - .outcome - .as_ref() - .filter(|cached| cached.key == *key) - .map(|cached| cached.outcome.clone()), - } + fn cached_loaded_plugins(&self, key: &PluginLoadCacheKey) -> Option> { + self.loaded_plugins_cache + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry + .as_ref() + .filter(|cached| cached.key == *key) + .map(|cached| cached.plugins.clone()) } - fn enabled_outcome_cache_generation(&self) -> u64 { - match self.enabled_outcome_cache.read() { + fn loaded_plugins_cache_generation(&self) -> u64 { + match self.loaded_plugins_cache.read() { Ok(cache) => cache.generation, Err(err) => err.into_inner().generation, } } - fn cache_enabled_outcome_if_current( + fn cache_loaded_plugins_if_current( &self, generation: u64, key: PluginLoadCacheKey, - outcome: PluginLoadOutcome, + plugins: Vec, + plugin_skill_snapshots: PluginSkillSnapshots, ) { - let mut cache = match self.enabled_outcome_cache.write() { + let mut cache = match self.loaded_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.generation == generation { - cache.outcome = Some(CachedPluginLoadOutcome { key, outcome }); + cache.entry = Some(LoadedPluginsCacheEntry { + key, + plugins, + plugin_skill_snapshots, + }); } } @@ -612,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], @@ -687,7 +843,7 @@ impl PluginsManager { } *cache = Some(plugins); drop(cache); - self.clear_enabled_outcome_cache(); + self.clear_loaded_plugins_cache(); true } @@ -701,11 +857,11 @@ impl PluginsManager { } *cache = None; drop(cache); - self.clear_enabled_outcome_cache(); + self.clear_loaded_plugins_cache(); true } - pub fn maybe_start_remote_installed_plugins_cache_refresh( + pub fn maybe_start_remote_plugin_caches_refresh( self: &Arc, config: &PluginsConfigInput, auth: Option, @@ -713,10 +869,18 @@ impl PluginsManager { ) { self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( config, - auth, + auth.clone(), RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged, on_effective_plugins_changed, ); + + let manager = Arc::clone(self); + let config = config.clone(); + tokio::spawn(async move { + manager + .recommended_plugins_mode_for_config(&config, auth.as_ref()) + .await; + }); } pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation( @@ -810,7 +974,7 @@ impl PluginsManager { if options.refresh_global_remote_catalog_cache { self.maybe_start_global_remote_catalog_cache_refresh(config, auth.clone()); } - self.maybe_start_remote_installed_plugins_cache_refresh( + self.maybe_start_remote_plugin_caches_refresh( config, auth.clone(), on_effective_plugins_changed.clone(), @@ -893,16 +1057,190 @@ impl PluginsManager { Ok(featured_plugin_ids) } + #[instrument( + level = "trace", + skip_all, + fields( + plugins_enabled = config.plugins_enabled, + remote_plugin_enabled = config.remote_plugin_enabled + ) + )] + pub async fn recommended_plugins_mode_for_config( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + ) -> RecommendedPluginsMode { + if !config.plugins_enabled + || !config.remote_plugin_enabled + || !auth.is_some_and(CodexAuth::uses_codex_backend) + { + return RecommendedPluginsMode::Legacy; + } + + let cache_key = recommended_plugins_cache_key(config); + if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) { + return cached; + } + + let refresh = { + let mut refreshes = match self.recommended_plugins_refreshes.write() { + Ok(refreshes) => refreshes, + Err(err) => err.into_inner(), + }; + if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) { + return cached; + } + refreshes + .entry(cache_key.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + + let mode = refresh + .get_or_init(|| async { + match crate::remote::fetch_recommended_plugins( + &remote_plugin_service_config(config), + auth, + ) + .await + { + Ok(mode) => { + let mut cache = match self.recommended_plugins_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache.insert(cache_key.clone(), mode.clone()); + mode + } + Err(err) => { + warn!(error = %err, "failed to load recommended plugins"); + RecommendedPluginsMode::Legacy + } + } + }) + .await + .clone(); + + let mut refreshes = match self.recommended_plugins_refreshes.write() { + Ok(refreshes) => refreshes, + Err(err) => err.into_inner(), + }; + if refreshes + .get(&cache_key) + .is_some_and(|current| Arc::ptr_eq(current, &refresh)) + { + refreshes.remove(&cache_key); + } + + mode + } + + /// Returns endpoint recommendations eligible for installation in the current client. + /// `None` selects the legacy discovery workflow. + #[instrument(level = "trace", skip_all)] + pub async fn recommended_plugin_candidates_for_config( + &self, + input: RecommendedPluginCandidatesInput<'_>, + ) -> Option> { + let RecommendedPluginsMode::Endpoint { plugins } = self + .recommended_plugins_mode_for_config(input.plugins_config, input.auth) + .await + else { + return None; + }; + if plugins.is_empty() { + return Some(Vec::new()); + } + + let installed_plugin_ids = input + .loaded_plugins + .plugins() + .iter() + .map(|plugin| plugin.config_name.as_str()) + .collect::>(); + let installed_remote_plugin_ids = { + let cache = match self.remote_installed_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache + .as_deref() + .unwrap_or_default() + .iter() + .filter(|plugin| plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME) + .map(|plugin| plugin.id.clone()) + .collect::>() + }; + let disabled_plugin_ids = input + .disabled_tools + .iter() + .filter(|tool| tool.kind == ToolSuggestDiscoverableType::Plugin) + .map(|tool| tool.id.as_str()) + .collect::>(); + + let candidates = plugins + .into_iter() + .filter(|plugin| { + !installed_plugin_ids.contains(plugin.config_id.as_str()) + && !installed_remote_plugin_ids.contains(plugin.remote_plugin_id.as_str()) + && !disabled_plugin_ids.contains(plugin.config_id.as_str()) + }) + .map(|plugin| { + DiscoverableTool::from(DiscoverablePluginInfo { + id: plugin.config_id, + remote_plugin_id: Some(plugin.remote_plugin_id), + name: plugin.display_name, + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: plugin.app_connector_ids, + }) + }) + .collect(); + Some(filter_request_plugin_install_discoverable_tools_for_client( + candidates, + input.app_server_client_name, + )) + } + + fn cached_recommended_plugins_mode( + &self, + cache_key: &RecommendedPluginsCacheKey, + ) -> Option { + let cache = match self.recommended_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache.get(cache_key).cloned() + } + pub async fn install_plugin( &self, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; - self.install_resolved_plugin(resolved).await + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } } pub async fn install_plugin_with_remote_sync( @@ -911,21 +1249,101 @@ impl PluginsManager { auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. - crate::remote_legacy::enable_remote_plugin( + if let Err(err) = crate::remote_legacy::enable_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_id, ) .await - .map_err(PluginInstallError::from)?; - self.install_resolved_plugin(resolved).await + { + let err = PluginInstallError::from(err); + self.track_plugin_install_failed( + &resolved.plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + return Err(err); + } + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } + } + + fn track_plugin_install_resolution_failed(&self, err: &MarketplaceError) { + let plugin_id = match err { + MarketplaceError::PluginNotFound { + plugin_name, + marketplace_name, + } + | MarketplaceError::PluginNotAvailable { + plugin_name, + marketplace_name, + } => PluginId::new(plugin_name.clone(), marketplace_name.clone()).ok(), + MarketplaceError::Io { .. } + | MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginsDisabled + | MarketplaceError::InvalidPlugin(_) => None, + }; + if let Some(plugin_id) = plugin_id { + self.track_plugin_install_failed( + &plugin_id, + marketplace_error_type(err), + err.to_string(), + ); + } else { + tracing::warn!( + error_type = %marketplace_error_type(err), + error = %err, + "plugin install failed while resolving marketplace plugin" + ); + } + } + + fn track_plugin_install_failed( + &self, + plugin_id: &PluginId, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + plugin_id = %plugin_id.as_key(), + error_type = %error_type, + error = %error_message, + "plugin install failed" + ); + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_plugin_install_failed( + self.telemetry_metadata_for_plugin_id(plugin_id), + error_type.to_string(), + ); + } } async fn install_resolved_plugin( @@ -947,15 +1365,32 @@ impl PluginsManager { }; let store = self.store.clone(); let codex_home = self.codex_home.clone(); + let manifest_fallback_contents = resolved + .manifest_fallback + .contents_if_has_metadata() + .map(str::to_string); let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || { let materialized = materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source) .map_err(PluginStoreError::Invalid)?; let source_path = materialized.path; - if let Some(plugin_version) = plugin_version { - store.install_with_version(source_path, resolved.plugin_id, plugin_version) - } else { - store.install(source_path, resolved.plugin_id) + match (plugin_version, manifest_fallback_contents.as_deref()) { + (Some(plugin_version), Some(manifest_contents)) => store + .install_with_version_and_fallback_manifest( + source_path, + resolved.plugin_id, + plugin_version, + manifest_contents, + ), + (Some(plugin_version), None) => { + store.install_with_version(source_path, resolved.plugin_id, plugin_version) + } + (None, Some(manifest_contents)) => store.install_with_fallback_manifest( + source_path, + resolved.plugin_id, + manifest_contents, + ), + (None, None) => store.install(source_path, resolved.plugin_id), } }) .await @@ -975,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, ); } @@ -1016,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 }; @@ -1085,6 +1523,7 @@ impl PluginsManager { let enabled = enabled_plugins.contains(&plugin_key); let mut interface = plugin.interface; let mut local_version = plugin.local_version; + let manifest_fallback = plugin.manifest_fallback.clone(); if installed && matches!(&plugin.source, MarketplacePluginSource::Git { .. }) && let Some(plugin_id) = plugin_id.as_ref() @@ -1115,6 +1554,7 @@ impl PluginsManager { policy: plugin.policy, keywords: plugin.keywords, interface, + manifest_fallback, }) }) .collect::>(); @@ -1150,6 +1590,19 @@ impl PluginsManager { )) } + pub(crate) async fn tool_suggest_metadata_for_marketplace_plugin( + &self, + marketplace_name: &str, + plugin: &ConfiguredMarketplacePlugin, + skill_config_rules: &SkillConfigRules, + ) -> Result { + let fragment = self + .tool_suggest_metadata_cache + .metadata_for_plugin(marketplace_name, plugin, self.restriction_product) + .await?; + Ok(fragment.project(skill_config_rules, self.auth_mode())) + } + pub async fn read_plugin_for_config( &self, config: &PluginsConfigInput, @@ -1169,6 +1622,10 @@ impl PluginsManager { let marketplace_name = plugin.plugin_id.marketplace_name.clone(); let plugin_key = plugin.plugin_id.as_key(); + let manifest_fallback = plugin + .manifest_fallback + .contents_if_has_metadata() + .map(|_| plugin.manifest_fallback.clone()); let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); let installed = installed_plugins.contains(&plugin_key); let installed_version = if installed { @@ -1196,6 +1653,7 @@ impl PluginsManager { .as_ref() .map(|manifest| manifest.keywords.clone()) .unwrap_or_default(), + manifest_fallback, installed, enabled: enabled_plugins.contains(&plugin_key), }, @@ -1282,9 +1740,18 @@ impl PluginsManager { "path does not exist or is not a directory".to_string(), )); } - let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| { - MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string()) - })?; + let manifest = + if codex_utils_plugins::find_plugin_manifest_path(source_path.as_path()).is_some() { + load_plugin_manifest(source_path.as_path()) + } else { + plugin + .manifest_fallback + .as_ref() + .and_then(|fallback| fallback.parse_for_plugin_root(source_path.as_path())) + } + .ok_or_else(|| { + MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string()) + })?; let description = manifest.description.clone(); let marketplace_category = plugin .interface @@ -1297,11 +1764,12 @@ impl PluginsManager { let resolved_skills = load_plugin_skills( &source_path, &plugin_id, - &manifest.paths, + &manifest, self.restriction_product, &codex_core_skills::config_rules::skill_config_rules_from_stack( &config.config_layer_stack, ), + /*plugin_skill_snapshots*/ None, ) .await; let plugin_data_root = self.store.plugin_data_root(&plugin_id); @@ -1315,8 +1783,14 @@ impl PluginsManager { }) .collect(); let auth_mode = self.auth_mode(); - let mut app_declarations = load_plugin_apps(source_path.as_path()).await; - let mut mcp_servers = load_plugin_mcp_servers(source_path.as_path(), auth_mode).await; + let mut app_declarations = + load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await; + let mut mcp_servers = load_plugin_mcp_servers_from_manifest( + source_path.as_path(), + &manifest.paths, + /*plugin_policy*/ None, + ) + .await; if auth_mode.is_some() { apply_app_mcp_routing_policy( &mut app_declarations, @@ -1367,7 +1841,11 @@ impl PluginsManager { on_effective_plugins_changed: Option>, ) { if config.plugins_enabled { - self.start_curated_repo_sync(); + let use_remote_global_catalog = + config.remote_plugin_enabled && auth_manager.current_auth_uses_codex_backend(); + if !use_remote_global_catalog { + self.start_curated_repo_sync(); + } let should_spawn_marketplace_auto_upgrade = { let mut state = match self.configured_marketplace_upgrade_state.write() { Ok(state) => state, @@ -1425,7 +1903,7 @@ impl PluginsManager { let on_effective_plugins_changed = on_effective_plugins_changed.clone(); tokio::spawn(async move { let auth = auth_manager_for_remote_sync.auth().await; - manager.maybe_start_remote_installed_plugins_cache_refresh( + manager.maybe_start_remote_plugin_caches_refresh( &config_for_remote_sync, auth.clone(), on_effective_plugins_changed.clone(), @@ -1458,12 +1936,12 @@ impl PluginsManager { } }); - let config = config.clone(); + let config_for_featured_plugins = config.clone(); let manager = Arc::clone(self); tokio::spawn(async move { let auth = auth_manager.auth().await; if let Err(err) = manager - .featured_plugin_ids_for_config(&config, auth.as_ref()) + .featured_plugin_ids_for_config(&config_for_featured_plugins, auth.as_ref()) .await { warn!( @@ -1501,9 +1979,7 @@ impl PluginsManager { &outcome.upgraded_roots, ) { Ok(cache_refreshed) => { - if cache_refreshed { - self.clear_cache(); - } + self.clear_caches_after_marketplace_source_refresh(cache_refreshed); } Err(err) => { self.clear_cache(); @@ -1683,9 +2159,8 @@ impl PluginsManager { &configured_curated_plugin_ids, ) { Ok(cache_refreshed) => { - if cache_refreshed { - manager.clear_cache(); - } + manager + .clear_caches_after_marketplace_source_refresh(cache_refreshed); } Err(err) => { manager.clear_cache(); @@ -1926,7 +2401,9 @@ impl PluginsManager { } } -fn remote_plugin_install_required_description(source: &MarketplacePluginSource) -> String { +pub(crate) fn remote_plugin_install_required_description( + source: &MarketplacePluginSource, +) -> String { let source_description = match source { MarketplacePluginSource::Git { url, @@ -1991,6 +2468,54 @@ impl PluginInstallError { } } +fn plugin_install_error_type(err: &PluginInstallError) -> &'static str { + match err { + PluginInstallError::Marketplace(err) => marketplace_error_type(err), + PluginInstallError::Remote(err) => remote_plugin_mutation_error_type(err), + PluginInstallError::Store(err) => plugin_store_error_type(err), + PluginInstallError::Config(_) => "config", + PluginInstallError::Join(_) => "join", + } +} + +fn marketplace_error_type(err: &MarketplaceError) -> &'static str { + match err { + MarketplaceError::Io { .. } => "marketplace_io", + MarketplaceError::MarketplaceNotFound { .. } => "marketplace_not_found", + MarketplaceError::InvalidMarketplaceFile { .. } => "invalid_marketplace_file", + MarketplaceError::PluginNotFound { .. } => "plugin_not_found", + MarketplaceError::PluginNotAvailable { .. } => "plugin_not_available", + MarketplaceError::PluginsDisabled => "plugins_disabled", + MarketplaceError::InvalidPlugin(_) => "invalid_plugin", + } +} + +fn remote_plugin_mutation_error_type(err: &RemotePluginMutationError) -> &'static str { + match err { + RemotePluginMutationError::AuthRequired => "remote_mutation_auth_required", + RemotePluginMutationError::UnsupportedAuthMode => "remote_mutation_unsupported_auth_mode", + RemotePluginMutationError::AuthToken(_) => "remote_mutation_auth_token", + RemotePluginMutationError::InvalidBaseUrl(_) => "remote_mutation_invalid_base_url", + RemotePluginMutationError::InvalidBaseUrlPath => "remote_mutation_invalid_base_url_path", + RemotePluginMutationError::Request { .. } => "remote_mutation_request", + RemotePluginMutationError::UnexpectedStatus { .. } => "remote_mutation_unexpected_status", + RemotePluginMutationError::Decode { .. } => "remote_mutation_decode", + RemotePluginMutationError::UnexpectedPluginId { .. } => { + "remote_mutation_unexpected_plugin_id" + } + RemotePluginMutationError::UnexpectedEnabledState { .. } => { + "remote_mutation_unexpected_enabled_state" + } + } +} + +fn plugin_store_error_type(err: &PluginStoreError) -> &'static str { + match err { + PluginStoreError::Io { .. } => "store_io", + PluginStoreError::Invalid(_) => "store_invalid", + } +} + #[derive(Debug, thiserror::Error)] pub enum PluginUninstallError { #[error("{0}")] diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index d6afbfb89991..50a5fe3e7362 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -3,7 +3,10 @@ use crate::LoadedPlugin; use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginLoadOutcome; +use crate::ToolSuggestDiscoverablePlugin; +use crate::ToolSuggestPluginDiscoveryInput; use crate::installed_marketplaces::marketplace_install_root; +use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; @@ -11,11 +14,13 @@ use crate::marketplace::MarketplacePluginInstallPolicy; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; +use crate::remote::RecommendedPlugin; use crate::remote::RemoteInstalledPlugin; use crate::startup_sync::curated_plugins_repo_path; use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; use crate::test_support::TEST_CURATED_PLUGIN_SHA; use crate::test_support::load_plugins_config as load_plugins_config_input; +use crate::test_support::write_curated_plugin; use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::test_support::write_file; use crate::test_support::write_openai_api_curated_marketplace; @@ -32,14 +37,21 @@ use codex_config::McpServerConfig; use codex_config::McpServerOAuthConfig; use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_core_skills::PluginSkillSnapshots; +use codex_core_skills::SkillsLoadInput; +use codex_core_skills::SkillsService; +use codex_core_skills::config_rules::SkillConfigRules; use codex_login::CodexAuth; use codex_plugin::AppDeclaration; +use codex_plugin::PluginId; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; +use std::time::Duration; use tempfile::TempDir; use toml::Value; use wiremock::Mock; @@ -359,7 +371,7 @@ enabled = true } #[tokio::test] -async fn plugin_auth_projection_reprojects_cached_outcome_when_auth_changes() { +async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() { let codex_home = TempDir::new().unwrap(); write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true); write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false); @@ -379,6 +391,27 @@ async fn plugin_auth_projection_reprojects_cached_outcome_when_auth_changes() { chatgpt_outcome.effective_apps(), vec![AppConnectorId("connector_sample".to_string())] ); + assert_eq!( + chatgpt_outcome.capability_summaries(), + &[ + PluginCapabilitySummary { + config_name: "docs@test".to_string(), + display_name: "docs".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["docs".to_string()], + app_connector_ids: Vec::new(), + }, + PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: vec![AppConnectorId("connector_sample".to_string())], + }, + ] + ); assert!(manager.set_auth_mode(Some(AuthMode::ApiKey))); let api_key_outcome = manager.plugins_for_config(&config).await; @@ -388,6 +421,27 @@ async fn plugin_auth_projection_reprojects_cached_outcome_when_auth_changes() { vec!["docs".to_string(), "sample".to_string()] ); assert!(api_key_outcome.effective_apps().is_empty()); + assert_eq!( + api_key_outcome.capability_summaries(), + &[ + PluginCapabilitySummary { + config_name: "docs@test".to_string(), + display_name: "docs".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["docs".to_string()], + app_connector_ids: Vec::new(), + }, + PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }, + ] + ); } fn write_plugin_with_version( @@ -575,6 +629,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: Some("sample".to_string()), + plugin_namespace: Some("sample".to_string()), manifest_description: Some( "Plugin that includes the sample MCP server and Skills".to_string(), ), @@ -638,6 +693,70 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[tokio::test] +async fn load_plugins_loads_manifest_mcp_server_objects() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/counter-sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "counter-sample", + "version": "1.1.1", + "description": "Plugin that declares MCP servers in the manifest", + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +}"#, + ); + + let config_toml = r#" +[features] +plugins = true + +[plugins."counter-sample@test"] +enabled = true +"#; + let outcome = + load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await; + + assert_eq!(outcome.plugins()[0].error, None); + assert_eq!( + outcome.plugins()[0].mcp_servers, + HashMap::from([( + "counter".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://sample.example/counter/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + 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(), + }, + )]) + ); +} + #[tokio::test] async fn load_plugins_applies_plugin_mcp_server_policy() { let codex_home = TempDir::new().unwrap(); @@ -726,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(); @@ -1035,14 +1229,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(), @@ -1054,6 +1248,47 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { ); } +#[tokio::test] +async fn plugin_capability_summary_uses_manifest_mcp_server_objects() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/counter-sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "counter-sample", + "version": "1.1.1", + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +}"#, + ); + + let summary = plugin_capability_summary_from_root( + &PluginId::parse("counter-sample@test").expect("plugin id should parse"), + &plugin_root.abs(), + ) + .await; + + assert_eq!( + summary, + Some(PluginCapabilitySummary { + config_name: "counter-sample@test".to_string(), + display_name: "counter-sample".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["counter".to_string()], + app_connector_ids: Vec::new(), + }) + ); +} + #[tokio::test] async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() { let codex_home = TempDir::new().unwrap(); @@ -1133,32 +1368,62 @@ async fn capability_summary_truncates_overlong_plugin_descriptions() { #[tokio::test] async fn load_plugins_uses_manifest_configured_component_paths() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); + for (skills_json, expected_skill_dirs) in [ + (r#""./custom-skills/""#, &["custom-skills"][..]), + ( + r#"["./custom-skills/", "./extra-skills/"]"#, + &["custom-skills", "extra-skills"][..], + ), + ( + r#"["./custom-skills/", "./custom-skills/"]"#, + &["custom-skills"][..], + ), + (r#""./skills/""#, &["skills"][..]), + ( + r#"["./skills/abc/", "./skills/edk/"]"#, + &["skills/abc", "skills/edk"][..], + ), + ] { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{ + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ "name": "sample", - "skills": "./custom-skills/", + "skills": {skills_json}, "mcpServers": "./config/custom.mcp.json", "apps": "./config/custom.app.json" -}"#, - ); - write_file( - &plugin_root.join("skills/default-skill/SKILL.md"), - "---\nname: default-skill\ndescription: default skill\n---\n", - ); - write_file( - &plugin_root.join("custom-skills/custom-skill/SKILL.md"), - "---\nname: custom-skill\ndescription: custom skill\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ +}}"# + ), + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("skills/abc/SKILL.md"), + "---\nname: abc\ndescription: abc skill\n---\n", + ); + write_file( + &plugin_root.join("skills/edk/SKILL.md"), + "---\nname: edk\ndescription: edk skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join("extra-skills/extra-skill/SKILL.md"), + "---\nname: extra-skill\ndescription: extra skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ "mcpServers": { "default": { "type": "http", @@ -1166,10 +1431,10 @@ async fn load_plugins_uses_manifest_configured_component_paths() { } } }"#, - ); - write_file( - &plugin_root.join("config/custom.mcp.json"), - r#"{ + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ "mcpServers": { "custom": { "type": "http", @@ -1177,74 +1442,140 @@ async fn load_plugins_uses_manifest_configured_component_paths() { } } }"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ "apps": { "default-app": { "id": "connector_default" } } }"#, - ); - write_file( - &plugin_root.join("config/custom.app.json"), - r#"{ + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ "apps": { "custom-app": { "id": "connector_custom" } } }"#, + ); + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + Some(AuthMode::Chatgpt), + ) + .await; + let mut expected_skill_roots = expected_skill_dirs + .iter() + .map(|dir| plugin_root.join(dir).abs()) + .collect::>(); + expected_skill_roots.sort_unstable(); + expected_skill_roots.dedup(); + + assert_eq!(outcome.plugins()[0].skill_roots, expected_skill_roots); + assert_eq!( + outcome.plugins()[0].mcp_servers, + HashMap::from([( + "custom".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://custom.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + 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(), + }, + )]) + ); + assert_eq!( + outcome.plugins()[0].apps, + vec![app_declaration("custom-app", "connector_custom")] + ); + } +} + +#[tokio::test] +async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local") + .abs(); + write_file( + &plugin_root.join("skills/abc/SKILL.md"), + "---\nname: abc\ndescription: abc skill\n---\n", + ); + write_file( + &plugin_root.join("skills/edk/SKILL.md"), + "---\nname: edk\ndescription: edk skill\n---\n", ); + let manifest = crate::manifest::PluginManifest { + name: "sample".to_string(), + version: None, + description: None, + keywords: Vec::new(), + paths: crate::manifest::PluginManifestPaths { + skills: vec![ + plugin_root.join("skills"), + plugin_root.join("skills/abc"), + plugin_root.join("skills/edk"), + plugin_root.join("skills/abc"), + ], + mcp_servers: None, + apps: None, + hooks: None, + }, + interface: None, + }; + let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); - let outcome = load_plugins_from_config( - &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), - codex_home.path(), - Some(AuthMode::Chatgpt), + let resolved = load_plugin_skills( + &plugin_root, + &plugin_id, + &manifest, + /*restriction_product*/ None, + &SkillConfigRules::default(), + /*plugin_skill_snapshots*/ None, ) .await; + let skill_paths = resolved + .skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + let canonical_skill_path = |path| { + AbsolutePathBuf::from_absolute_path_checked( + fs::canonicalize(plugin_root.join(path)).expect("canonical skill path"), + ) + .expect("absolute skill path") + }; assert_eq!( - outcome.plugins()[0].skill_roots, + skill_paths, vec![ - plugin_root.join("custom-skills").abs(), - plugin_root.join("skills").abs() + canonical_skill_path("skills/abc/SKILL.md"), + canonical_skill_path("skills/edk/SKILL.md") ] ); - assert_eq!( - outcome.plugins()[0].mcp_servers, - HashMap::from([( - "custom".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://custom.example/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - environment_id: "local".to_string(), - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: None, - 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(), - }, - )]) - ); - assert_eq!( - outcome.plugins()[0].apps, - vec![app_declaration("custom-app", "connector_custom")] - ); } #[tokio::test] @@ -1372,7 +1703,7 @@ async fn load_plugins_ignores_invalid_manifest_skills_shape() { &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", - "skills": ["./custom-skills/"] + "skills": { "path": "./custom-skills/" } }"#, ); write_file( @@ -1436,6 +1767,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, + plugin_namespace: None, manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, @@ -1604,6 +1936,12 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { config_name: config_name.to_string(), manifest_name: Some(manifest_name.to_string()), + plugin_namespace: Some( + config_name + .split_once('@') + .map_or(config_name, |(name, _)| name) + .to_string(), + ), manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, @@ -1780,8 +2118,57 @@ async fn plugin_cache_ignores_unrelated_session_overrides() { assert_eq!(second.plugins()[0].mcp_servers.len(), 1); } +#[tokio::test] +async fn skills_service_reuses_skills_parsed_during_plugin_load() { + let codex_home = TempDir::new().unwrap(); + let codex_home_abs = codex_home.path().to_path_buf().abs(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_plugin( + codex_home.path().join("plugins/cache/test").as_path(), + "sample/local", + "sample", + ); + let skill_path = plugin_root.join("skills/SKILL.md"); + write_file(&skill_path, "---\nname: search\ndescription: first\n---\n"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_outcome = manager.plugins_for_config(&config).await; + let plugin_skill_snapshots = manager.plugin_skill_snapshots_for_config(&config); + write_file(&skill_path, "---\nname: search\ndescription: second\n---\n"); + + let skills_input = SkillsLoadInput::new( + codex_home_abs.clone(), + plugin_outcome.effective_plugin_skill_roots(), + config.config_layer_stack.clone(), + /*bundled_skills_enabled*/ false, + ) + .with_plugin_skill_snapshots(plugin_skill_snapshots); + let skills_service = SkillsService::new(codex_home_abs, /*bundled_skills_enabled*/ false); + let cached = skills_service + .snapshot_for_config(&skills_input, /*fs*/ None) + .await; + + assert_eq!( + cached + .outcome() + .skills + .iter() + .map(|skill| skill.description.as_str()) + .collect::>(), + vec!["first"] + ); +} + #[test] -fn plugin_cache_invalidation_rejects_stale_load_completion() { +fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() { let codex_home = TempDir::new().unwrap(); let manager = PluginsManager::new(codex_home.path().to_path_buf()); let cache_key = PluginLoadCacheKey { @@ -1789,16 +2176,17 @@ fn plugin_cache_invalidation_rejects_stale_load_completion() { skill_config_rules: SkillConfigRules::default(), remote_plugin_enabled: false, }; - let stale_generation = manager.enabled_outcome_cache_generation(); + let stale_generation = manager.loaded_plugins_cache_generation(); - manager.clear_enabled_outcome_cache(); - manager.cache_enabled_outcome_if_current( + manager.clear_loaded_plugins_cache(); + manager.cache_loaded_plugins_if_current( stale_generation, cache_key.clone(), - PluginLoadOutcome::default(), + Vec::new(), + PluginSkillSnapshots::for_plugin_load(), ); - assert_eq!(manager.cached_enabled_outcome(&cache_key), None); + assert_eq!(manager.cached_loaded_plugins(&cache_key), None); } #[tokio::test] @@ -1986,6 +2374,98 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { ); } +#[tokio::test] +async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin_json() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/quality-review"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap(); + fs::write( + plugin_root.join("skills/thermo-nuclear-code-quality-review/SKILL.md"), + "review skill", + ) + .unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "quality-review", + "description": "Strict code quality review focused on maintainability.", + "source": "./plugins/quality-review", + "author": { + "name": "Byron Grogan" + }, + "skills": [ + "./skills/thermo-nuclear-code-quality-review" + ], + "category": "code-review" + } + ] +}"#, + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "quality-review".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/quality-review/local"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("quality-review".to_string(), "debug".to_string()).unwrap(), + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); + assert!(!plugin_root.join(".codex-plugin/plugin.json").exists()); + assert!( + !tmp.path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); + + let manifest = crate::manifest::load_plugin_manifest(&installed_path).unwrap(); + assert_eq!(manifest.name, "quality-review"); + assert_eq!( + manifest.description.as_deref(), + Some("Strict code quality review focused on maintainability.") + ); + assert_eq!( + manifest.paths.skills, + vec![ + AbsolutePathBuf::try_from( + installed_path.join("skills/thermo-nuclear-code-quality-review") + ) + .unwrap() + ] + ); + let interface = manifest.interface.expect("fallback interface"); + assert_eq!(interface.developer_name.as_deref(), Some("Byron Grogan")); + assert_eq!(interface.category.as_deref(), Some("code-review")); + let fallback_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(installed_path.join(".codex-plugin/plugin.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + fallback_json["author"], + serde_json::json!({ "name": "Byron Grogan" }) + ); + assert_eq!(fallback_json["category"], "code-review"); +} + #[tokio::test] async fn install_plugin_supports_git_subdir_marketplace_sources() { let tmp = tempfile::tempdir().unwrap(); @@ -2229,6 +2709,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: true, enabled: true, }, @@ -2248,6 +2729,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: true, enabled: false, }, @@ -2377,6 +2859,7 @@ plugins = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }] @@ -2453,18 +2936,177 @@ async fn read_plugin_for_config_filters_mcp_servers_for_codex_backend_auth() { ] }"#, ); - write_file( - &repo_root.join("sample-plugin/.codex-plugin/plugin.json"), - r#"{"name":"sample-plugin"}"#, - ); - write_file( - &repo_root.join("sample-plugin/.app.json"), - r#"{"apps":{"sample-mcp":{"id":"connector_sample"}}}"#, - ); - write_file( - &repo_root.join("sample-plugin/.mcp.json"), - r#"{"mcpServers":{"other-mcp":{"command":"other-mcp"},"sample-mcp":{"command":"sample-mcp"}}}"#, - ); + write_file( + &repo_root.join("sample-plugin/.codex-plugin/plugin.json"), + r#"{"name":"sample-plugin"}"#, + ); + write_file( + &repo_root.join("sample-plugin/.app.json"), + r#"{"apps":{"sample-mcp":{"id":"connector_sample"}}}"#, + ); + write_file( + &repo_root.join("sample-plugin/.mcp.json"), + r#"{"mcpServers":{"other-mcp":{"command":"other-mcp"},"sample-mcp":{"command":"sample-mcp"}}}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let request = PluginReadRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }; + + let chatgpt_outcome = PluginsManager::new_with_options( + tmp.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + ) + .read_plugin_for_config(&config, &request) + .await + .unwrap(); + assert_eq!( + chatgpt_outcome.plugin.mcp_server_names, + vec!["other-mcp".to_string()] + ); + assert_eq!( + chatgpt_outcome.plugin.apps, + vec![AppConnectorId("connector_sample".to_string())] + ); + + let api_key_outcome = PluginsManager::new_with_options( + tmp.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::ApiKey), + ) + .read_plugin_for_config(&config, &request) + .await + .unwrap(); + assert_eq!( + api_key_outcome.plugin.mcp_server_names, + vec!["other-mcp".to_string(), "sample-mcp".to_string()] + ); + assert!(api_key_outcome.plugin.apps.is_empty()); +} + +#[tokio::test] +async fn read_plugin_for_config_uses_marketplace_manifest_fallback_paths_for_local_source() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("sample-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": "./sample-plugin", + "apps": "./config/custom.app.json", + "mcpServers": { + "sample-mcp": { + "command": "sample-mcp" + } + } + } + ] +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{"apps":{"sample-app":{"id":"connector_sample"}}}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let outcome = manager + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .await + .unwrap(); + + assert_eq!( + outcome.plugin.apps, + vec![AppConnectorId("connector_sample".to_string())] + ); + assert_eq!( + outcome.plugin.mcp_server_names, + vec!["sample-mcp".to_string()] + ); + + let listed_plugin = manager + .list_marketplaces_for_config( + &config, + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*include_openai_curated*/ false, + ) + .unwrap() + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "debug") + .unwrap() + .plugins + .into_iter() + .find(|plugin| plugin.name == "sample-plugin") + .unwrap(); + let listed_detail = manager + .read_plugin_detail_for_marketplace_plugin(&config, "debug", listed_plugin) + .await + .unwrap(); + assert_eq!( + listed_detail.apps, + vec![AppConnectorId("connector_sample".to_string())] + ); + assert_eq!( + listed_detail.mcp_server_names, + vec!["sample-mcp".to_string()] + ); +} + +#[tokio::test] +async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("sample-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": "./sample-plugin", + "description": "Fallback metadata" + } + ] +}"#, + ); + write_file(&plugin_root.join(".codex-plugin/plugin.json"), "{"); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] @@ -2473,44 +3115,21 @@ plugins = true ); let config = load_config(tmp.path(), &repo_root).await; - let request = PluginReadRequest { - plugin_name: "sample-plugin".to_string(), - marketplace_path: AbsolutePathBuf::try_from( - repo_root.join(".agents/plugins/marketplace.json"), + let err = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, ) - .unwrap(), - }; - - let chatgpt_outcome = PluginsManager::new_with_options( - tmp.path().to_path_buf(), - Some(Product::Codex), - Some(AuthMode::Chatgpt), - ) - .read_plugin_for_config(&config, &request) - .await - .unwrap(); - assert_eq!( - chatgpt_outcome.plugin.mcp_server_names, - vec!["other-mcp".to_string()] - ); - assert_eq!( - chatgpt_outcome.plugin.apps, - vec![AppConnectorId("connector_sample".to_string())] - ); + .await + .unwrap_err(); - let api_key_outcome = PluginsManager::new_with_options( - tmp.path().to_path_buf(), - Some(Product::Codex), - Some(AuthMode::ApiKey), - ) - .read_plugin_for_config(&config, &request) - .await - .unwrap(); - assert_eq!( - api_key_outcome.plugin.mcp_server_names, - vec!["other-mcp".to_string(), "sample-mcp".to_string()] - ); - assert!(api_key_outcome.plugin.apps.is_empty()); + assert_eq!(err.to_string(), "missing or invalid plugin.json"); } #[tokio::test] @@ -2895,8 +3514,11 @@ enabled = true .find(|marketplace| marketplace.name == "debug") .expect("debug marketplace should be listed"); + let mut plugins = marketplace.plugins; + assert!(plugins[0].manifest_fallback.is_some()); + plugins[0].manifest_fallback = None; assert_eq!( - marketplace.plugins, + plugins, vec![ConfiguredMarketplacePlugin { id: "toolkit@debug".to_string(), name: "toolkit".to_string(), @@ -2931,6 +3553,7 @@ enabled = true ..Default::default() }), keywords: Vec::new(), + manifest_fallback: None, installed: true, enabled: true, }] @@ -3011,6 +3634,7 @@ plugins = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }], @@ -3133,6 +3757,7 @@ plugins = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }], @@ -3245,6 +3870,103 @@ source = "/tmp/debug" ); } +#[tokio::test] +async fn configured_marketplace_upgrade_invalidates_cached_tool_suggest_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let remote_repo = tmp.path().join("remote-marketplace"); + let remote_repo_url = url::Url::from_directory_path(&remote_repo) + .unwrap() + .to_string(); + write_file( + &remote_repo.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + ); + write_curated_plugin(&remote_repo, "sample"); + write_file( + &remote_repo.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample","description":"Before upgrade"}"#, + ); + init_git_repo(&remote_repo); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true + +[marketplaces.debug] +source_type = "git" +source = "{remote_repo_url}" +"# + ), + ); + + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let config = load_config(tmp.path(), tmp.path()).await; + let initial_upgrade = manager + .upgrade_configured_marketplaces_for_config(&config, /*marketplace_name*/ None) + .expect("initial marketplace install should succeed"); + assert_eq!(initial_upgrade.errors, Vec::new()); + assert_eq!(initial_upgrade.upgraded_roots.len(), 1); + + let config = load_config(tmp.path(), tmp.path()).await; + let input = ToolSuggestPluginDiscoveryInput { + plugins: config.clone(), + configured_plugin_ids: HashSet::from(["sample@debug".to_string()]), + disabled_plugin_ids: HashSet::new(), + loaded_plugin_app_connector_ids: HashSet::new(), + }; + let expected = ToolSuggestDiscoverablePlugin { + id: "sample@debug".to_string(), + remote_plugin_id: None, + name: "sample".to_string(), + description: Some("Before upgrade".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }; + assert_eq!( + manager + .list_tool_suggest_discoverable_plugins(&input, /*auth*/ None) + .await + .expect("initial tool-suggest metadata should load"), + vec![expected.clone()] + ); + + write_file( + &remote_repo.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample","description":"After upgrade"}"#, + ); + run_git(&remote_repo, &["add", "."]); + run_git(&remote_repo, &["commit", "-m", "update plugin"]); + let upgrade = manager + .upgrade_configured_marketplaces_for_config(&config, /*marketplace_name*/ None) + .expect("marketplace upgrade should succeed"); + assert_eq!(upgrade.errors, Vec::new()); + assert_eq!(upgrade.upgraded_roots.len(), 1); + + assert_eq!( + manager + .list_tool_suggest_discoverable_plugins(&input, /*auth*/ None) + .await + .expect("refreshed tool-suggest metadata should load"), + vec![ToolSuggestDiscoverablePlugin { + description: Some("After upgrade".to_string()), + ..expected + }] + ); +} + #[tokio::test] async fn list_marketplaces_uses_config_when_known_registry_is_malformed() { let tmp = tempfile::tempdir().unwrap(); @@ -3463,6 +4185,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: true, }] @@ -3495,6 +4218,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }] @@ -3585,6 +4309,7 @@ enabled = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: true, }], @@ -3665,6 +4390,309 @@ plugins = true assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]); } +#[tokio::test] +async fn remote_plugin_caches_refresh_warms_recommended_plugins_cache() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [] + }))) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = std::sync::Arc::new(PluginsManager::new(tmp.path().to_path_buf())); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let cache_key = recommended_plugins_cache_key(&config); + + manager.maybe_start_remote_plugin_caches_refresh( + &config, + Some(auth.clone()), + /*on_effective_plugins_changed*/ None, + ); + + let mode = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(mode) = manager.cached_recommended_plugins_mode(&cache_key) { + break mode; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("recommended plugins cache should be warmed"); + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: Vec::new() + } + ); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + mode + ); + manager.clear_recommended_plugins_cache(); + assert_eq!(manager.cached_recommended_plugins_mode(&cache_key), None); +} + +#[tokio::test] +async fn recommended_plugins_mode_deduplicates_concurrent_cache_misses() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(header("OAI-Product-Sku", "codex")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_slack", + "name": "slack", + "release": { + "display_name": "Slack", + "app_ids": ["connector_slack"] + } + }, + { + "id": "plugin_github", + "name": "github", + "release": {"display_name": "GitHub"} + } + ] + })) + .set_delay(Duration::from_millis(100)), + ) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let expected = RecommendedPluginsMode::Endpoint { + plugins: vec![ + RecommendedPlugin { + config_id: "github@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_github".to_string(), + display_name: "GitHub".to_string(), + app_connector_ids: Vec::new(), + }, + RecommendedPlugin { + config_id: "slack@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_slack".to_string(), + display_name: "Slack".to_string(), + app_connector_ids: vec!["connector_slack".to_string()], + }, + ], + }; + + let (left, right) = tokio::join!( + manager.recommended_plugins_mode_for_config(&config, Some(&auth)), + manager.recommended_plugins_mode_for_config(&config, Some(&auth)), + ); + assert_eq!((left, right), (expected.clone(), expected.clone())); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + expected + ); +} + +#[tokio::test] +async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_linear", + "name": "linear", + "release": {"display_name": "Linear"} + }, + { + "id": "plugin_github", + "name": "github", + "release": {"display_name": "GitHub"} + }, + { + "id": "plugin_slack", + "name": "slack", + "release": {"display_name": "Slack"} + } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let mut installed_linear = remote_installed_plugin("linear"); + installed_linear.id = "plugin_linear".to_string(); + manager.write_remote_installed_plugins_cache(vec![installed_linear]); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let disabled_tools = [ToolSuggestDisabledTool::plugin( + "github@openai-curated-remote", + )]; + let loaded_plugins = manager.plugins_for_config(&config).await; + + let candidates = manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &config, + loaded_plugins: &loaded_plugins, + auth: Some(&auth), + disabled_tools: &disabled_tools, + app_server_client_name: None, + }) + .await; + + assert_eq!( + candidates, + Some(vec![DiscoverableTool::from(DiscoverablePluginInfo { + id: "slack@openai-curated-remote".to_string(), + remote_plugin_id: Some("plugin_slack".to_string()), + name: "Slack".to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + })]) + ); +} + +#[tokio::test] +async fn recommended_plugins_mode_caches_explicit_false() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": false, + "plugins": [] + }))) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Legacy + ); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Legacy + ); +} + +#[tokio::test] +async fn recommended_plugins_mode_retries_after_fetch_failure() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(500).set_body_string("unavailable")) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Legacy + ); + + server.reset().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [] + }))) + .expect(1) + .mount(&server) + .await; + + assert_eq!( + manager + .recommended_plugins_mode_for_config(&config, Some(&auth)) + .await, + RecommendedPluginsMode::Endpoint { + plugins: Vec::new() + } + ); +} + #[test] fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() { let tmp = tempfile::tempdir().unwrap(); @@ -4265,16 +5293,17 @@ async fn load_plugins_ignores_project_config_files() { ) .expect("config layer stack should build"); - let outcome = load_plugins_from_layer_stack( + let plugins = load_plugins_from_layer_stack( &stack, std::collections::HashMap::new(), &PluginStore::new(codex_home.path().to_path_buf()), + /*plugin_skill_snapshots*/ None, Some(Product::Codex), /*prefer_remote_curated_conflicts*/ false, ) .await; - assert_eq!(outcome, PluginLoadOutcome::default()); + assert_eq!(plugins, Vec::new()); } #[tokio::test] diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 28a200ab8606..37630a8436c5 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -12,6 +12,8 @@ const MAX_DEFAULT_PROMPT_LEN: usize = 128; pub type PluginManifest = codex_plugin::manifest::PluginManifest; pub type PluginManifestHooks = codex_plugin::manifest::PluginManifestHooks; pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterface; +pub type PluginManifestMcpServers = + codex_plugin::manifest::PluginManifestMcpServers; pub type PluginManifestPaths = codex_plugin::manifest::PluginManifestPaths; #[derive(Debug, Default, Deserialize)] @@ -28,9 +30,9 @@ struct RawPluginManifest { // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] - skills: Option, + skills: Option, #[serde(default)] - mcp_servers: Option, + mcp_servers: Option, #[serde(default)] apps: Option, #[serde(default)] @@ -92,8 +94,17 @@ enum RawPluginManifestDefaultPromptEntry { #[derive(Debug, Deserialize)] #[serde(untagged)] -enum RawPluginManifestPath { +enum RawPluginManifestPaths { Path(String), + Paths(Vec), + Invalid(JsonValue), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawPluginManifestMcpServers { + Path(String), + Object(std::collections::BTreeMap), Invalid(JsonValue), } @@ -220,8 +231,8 @@ pub(crate) fn parse_plugin_manifest( description, keywords, paths: PluginManifestPaths { - skills: resolve_manifest_path_value(plugin_root, "skills", skills.as_ref()), - mcp_servers: resolve_manifest_path(plugin_root, "mcpServers", mcp_servers.as_deref()), + skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()), + mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), }, @@ -259,6 +270,32 @@ fn resolve_manifest_hooks( } } +fn resolve_manifest_mcp_servers( + plugin_root: &Path, + mcp_servers: Option, +) -> Option { + match mcp_servers? { + RawPluginManifestMcpServers::Path(path) => { + resolve_manifest_path(plugin_root, "mcpServers", Some(&path)) + .map(PluginManifestMcpServers::Path) + } + RawPluginManifestMcpServers::Object(servers) => match serde_json::to_string(&servers) { + Ok(servers) => Some(PluginManifestMcpServers::Object(servers)), + Err(err) => { + tracing::warn!("ignoring mcpServers: failed to serialize object: {err}"); + None + } + }, + RawPluginManifestMcpServers::Invalid(value) => { + tracing::warn!( + "ignoring mcpServers: expected a string or object; found {}", + json_value_type(&value) + ); + None + } + } +} + fn resolve_interface_asset_path( plugin_root: &Path, field: &'static str, @@ -359,20 +396,29 @@ fn json_value_type(value: &JsonValue) -> &'static str { } } -fn resolve_manifest_path_value( +fn resolve_manifest_paths( plugin_root: &Path, field: &'static str, - path: Option<&RawPluginManifestPath>, -) -> Option { - match path? { - RawPluginManifestPath::Path(path) => resolve_manifest_path(plugin_root, field, Some(path)), - RawPluginManifestPath::Invalid(value) => { + paths: Option<&RawPluginManifestPaths>, +) -> Vec { + match paths { + Some(RawPluginManifestPaths::Path(path)) => { + resolve_manifest_path(plugin_root, field, Some(path)) + .map(|path| vec![path]) + .unwrap_or_default() + } + Some(RawPluginManifestPaths::Paths(paths)) => paths + .iter() + .filter_map(|path| resolve_manifest_path(plugin_root, field, Some(path))) + .collect(), + Some(RawPluginManifestPaths::Invalid(value)) => { tracing::warn!( - "ignoring {field}: expected a string; found {}", + "ignoring {field}: expected a string or string array; found {}", json_value_type(value) ); - None + Vec::new() } + None => Vec::new(), } } diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 228495c48bed..456bd9004c5f 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -8,6 +8,7 @@ use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; use std::fs; use std::io; @@ -29,6 +30,7 @@ pub struct ResolvedMarketplacePlugin { pub policy: MarketplacePluginPolicy, pub interface: Option, pub manifest: Option, + pub manifest_fallback: MarketplacePluginManifestFallback, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,6 +58,57 @@ pub struct MarketplaceInterface { pub display_name: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplacePluginManifestFallback { + contents: String, + has_metadata: bool, +} + +impl MarketplacePluginManifestFallback { + pub fn contents(&self) -> &str { + &self.contents + } + + pub(crate) fn contents_if_has_metadata(&self) -> Option<&str> { + self.has_metadata.then_some(self.contents()) + } + + pub(crate) fn parse_for_plugin_root( + &self, + plugin_root: &Path, + ) -> Option { + crate::manifest::parse_plugin_manifest( + plugin_root, + &fallback_plugin_manifest_path(plugin_root), + &self.contents, + ) + .ok() + } + + pub(crate) fn parse_for_listing(&self) -> Option { + // Git sources have no plugin root before install. Parse against a synthetic absolute root, + // then discard path-bearing fields so listings expose metadata only. + let mut manifest = crate::manifest::parse_plugin_manifest( + Path::new("/"), + Path::new("/.codex-plugin/plugin.json"), + &self.contents, + ) + .ok()?; + manifest.paths = crate::manifest::PluginManifestPaths { + skills: Vec::new(), + mcp_servers: None, + apps: None, + hooks: None, + }; + if let Some(interface) = manifest.interface.as_mut() { + interface.composer_icon = None; + interface.logo = None; + interface.screenshots.clear(); + } + Some(manifest) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePlugin { pub name: String, @@ -64,9 +117,10 @@ pub struct MarketplacePlugin { pub policy: MarketplacePluginPolicy, pub interface: Option, pub keywords: Vec, + pub manifest_fallback: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MarketplacePluginSource { Local { path: AbsolutePathBuf, @@ -313,6 +367,10 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result return Err(err), }; + let manifest_fallback = plugin + .manifest_fallback + .contents_if_has_metadata() + .map(|_| plugin.manifest_fallback.clone()); let local_version = plugin .manifest .as_ref() @@ -329,6 +387,7 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result load_plugin_manifest(path.as_path()), + MarketplacePluginSource::Local { path } => { + if codex_utils_plugins::find_plugin_manifest_path(path.as_path()).is_some() { + load_plugin_manifest(path.as_path()) + } else if manifest_fallback.has_metadata { + manifest_fallback.parse_for_plugin_root(path.as_path()) + } else { + None + } + } + MarketplacePluginSource::Git { .. } if manifest_fallback.has_metadata => { + manifest_fallback.parse_for_listing() + } MarketplacePluginSource::Git { .. } => None, }; let interface = plugin_interface_with_marketplace_category( @@ -462,6 +535,7 @@ fn resolve_marketplace_plugin_entry( }, interface, manifest, + manifest_fallback, })) } @@ -544,20 +618,26 @@ fn resolve_local_plugin_source_path( marketplace_path: &AbsolutePathBuf, path: &str, ) -> Result { - let Some(path) = path.strip_prefix("./") else { + match path { + "" => { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must not be empty".to_string(), + }); + } + "." | "./" => return marketplace_root_dir(marketplace_path), + _ => {} + } + + // Non-root local sources must keep the explicit `./` prefix and remain normalized. + let Some(relative_path) = path.strip_prefix("./") else { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must start with `./`".to_string(), }); }; - if path.is_empty() { - return Err(MarketplaceError::InvalidMarketplaceFile { - path: marketplace_path.to_path_buf(), - message: "local plugin source path must not be empty".to_string(), - }); - } - let relative_source_path = Path::new(path); + let relative_source_path = Path::new(relative_path); if relative_source_path .components() .any(|component| !matches!(component, Component::Normal(_))) @@ -759,6 +839,9 @@ struct RawMarketplaceManifestPlugin { policy: RawMarketplaceManifestPluginPolicy, #[serde(default)] category: Option, + #[serde(default)] + #[serde(flatten)] + manifest_fields: JsonMap, } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] @@ -816,6 +899,85 @@ fn resolve_marketplace_interface( } } +fn fallback_plugin_manifest_path(plugin_root: &Path) -> PathBuf { + plugin_root.join(".codex-plugin/plugin.json") +} + +fn marketplace_plugin_manifest_fallback( + name: &str, + category: Option<&str>, + manifest_fields: &JsonMap, +) -> MarketplacePluginManifestFallback { + let mut manifest = manifest_fields.clone(); + manifest.insert("name".to_string(), JsonValue::String(name.to_string())); + if let Some(category) = category { + manifest.insert( + "category".to_string(), + JsonValue::String(category.to_string()), + ); + } + if let Some(interface) = plugin_manifest_interface(manifest_fields, category) { + manifest.insert("interface".to_string(), interface); + } + + let contents = serde_json::to_string_pretty(&JsonValue::Object(manifest)) + .unwrap_or_else(|_| format!(r#"{{"name":"{name}"}}"#)); + MarketplacePluginManifestFallback { + contents, + has_metadata: !manifest_fields.is_empty() || category.is_some(), + } +} + +fn plugin_manifest_interface( + fields: &JsonMap, + category: Option<&str>, +) -> Option { + let mut interface = fields + .get("interface") + .and_then(JsonValue::as_object) + .cloned() + .unwrap_or_default(); + + if !interface.contains_key("displayName") + && let Some(display_name) = fields.get("displayName").and_then(JsonValue::as_str) + { + interface.insert( + "displayName".to_string(), + JsonValue::String(display_name.to_string()), + ); + } + if !interface.contains_key("developerName") + && let Some(author_name) = fields + .get("author") + .and_then(|author| author.get("name")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + interface.insert( + "developerName".to_string(), + JsonValue::String(author_name.to_string()), + ); + } + if !interface.contains_key("websiteUrl") + && !interface.contains_key("websiteURL") + && let Some(homepage) = fields.get("homepage").and_then(JsonValue::as_str) + { + interface.insert( + "websiteUrl".to_string(), + JsonValue::String(homepage.to_string()), + ); + } + if let Some(category) = category.map(str::trim).filter(|value| !value.is_empty()) { + interface.insert( + "category".to_string(), + JsonValue::String(category.to_string()), + ); + } + + (!interface.is_empty()).then_some(JsonValue::Object(interface)) +} + #[cfg(test)] #[path = "marketplace_tests.rs"] mod tests; diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 47c88b73aa58..45aa2364e2e7 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -20,6 +20,17 @@ fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) { fs::write(manifest_path, contents).unwrap(); } +fn minimal_manifest_fallback(name: &str) -> MarketplacePluginManifestFallback { + MarketplacePluginManifestFallback { + contents: format!( + r#"{{ + "name": "{name}" +}}"# + ), + has_metadata: false, + } +} + #[test] fn find_marketplace_plugin_finds_repo_marketplace_plugin() { let tmp = tempdir().unwrap(); @@ -65,6 +76,7 @@ fn find_marketplace_plugin_finds_repo_marketplace_plugin() { }, interface: None, manifest: None, + manifest_fallback: minimal_manifest_fallback("local-plugin"), } ); } @@ -108,6 +120,7 @@ fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() { }, interface: None, manifest: None, + manifest_fallback: minimal_manifest_fallback("string-source-plugin"), } ); } @@ -162,8 +175,217 @@ fn find_marketplace_plugin_supports_git_subdir_sources() { }, interface: None, manifest: None, + manifest_fallback: minimal_manifest_fallback("remote-plugin"), + } + ); +} + +#[test] +fn find_marketplace_plugin_builds_manifest_fallback_from_entry() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/quality-review"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap(); + fs::create_dir_all(plugin_root.join("skills/second-review")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r##"{ + "name": "team-marketplace", + "plugins": [ + { + "name": "quality-review", + "version": "1.2.3", + "description": "Strict code quality review focused on maintainability.", + "displayName": "Quality Review", + "source": "./plugins/quality-review", + "author": { + "name": "Byron Grogan" + }, + "homepage": "https://example.com/quality", + "repository": "https://github.com/example/quality-review", + "license": "MIT", + "skills": [ + "./skills/thermo-nuclear-code-quality-review", + "./skills/second-review" + ], + "commands": ["./commands/review.md"], + "mcpServers": { + "review": { + "type": "stdio", + "command": "review-mcp" + } + }, + "apps": "./apps/app.json", + "hooks": ["./hooks/session.json"], + "agents": [ + "./agents/thermo-nuclear-code-quality-review.md" + ], + "category": "code-review", + "keywords": ["quality", "review"], + "strict": false, + "interface": { + "shortDescription": "Interface short description.", + "longDescription": "Runs strict reviews focused on maintainability and boundaries.", + "category": "interface-category", + "capabilities": ["review", "quality"], + "privacyPolicyURL": "https://example.com/privacy", + "termsOfServiceUrl": "https://example.com/terms", + "defaultPrompt": [ + "Review this change", + "Find structural issues" + ], + "brandColor": "#00AAFF", + "composerIcon": "./assets/icon.svg", + "logo": "./assets/logo.png", + "screenshots": ["./assets/shot.png"] + } + } + ] +}"##, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "quality-review", + ) + .unwrap(); + + let manifest = resolved.manifest.as_ref().expect("fallback manifest"); + assert_eq!(manifest.name, "quality-review"); + assert_eq!(manifest.version.as_deref(), Some("1.2.3")); + assert_eq!( + manifest.description.as_deref(), + Some("Strict code quality review focused on maintainability.") + ); + assert_eq!( + manifest.paths.skills, + vec![ + AbsolutePathBuf::try_from( + plugin_root.join("skills/thermo-nuclear-code-quality-review") + ) + .unwrap(), + AbsolutePathBuf::try_from(plugin_root.join("skills/second-review")).unwrap(), + ] + ); + let Some(crate::manifest::PluginManifestMcpServers::Object(mcp_servers)) = + manifest.paths.mcp_servers.as_ref() + else { + panic!("fallback mcpServers should be inline"); + }; + assert_eq!( + serde_json::from_str::(mcp_servers).unwrap(), + serde_json::json!({ + "review": { + "type": "stdio", + "command": "review-mcp" + } + }) + ); + assert_eq!( + manifest.paths.apps.as_ref(), + Some(&AbsolutePathBuf::try_from(plugin_root.join("apps/app.json")).unwrap()) + ); + assert_eq!( + manifest.paths.hooks.as_ref(), + Some(&crate::manifest::PluginManifestHooks::Paths(vec![ + AbsolutePathBuf::try_from(plugin_root.join("hooks/session.json")).unwrap() + ])) + ); + assert_eq!(manifest.keywords, vec!["quality", "review"]); + let interface = manifest.interface.as_ref().expect("fallback interface"); + assert_eq!( + interface, + &PluginManifestInterface { + display_name: Some("Quality Review".to_string()), + short_description: Some("Interface short description.".to_string()), + long_description: Some( + "Runs strict reviews focused on maintainability and boundaries.".to_string() + ), + developer_name: Some("Byron Grogan".to_string()), + category: Some("code-review".to_string()), + capabilities: vec!["review".to_string(), "quality".to_string()], + website_url: Some("https://example.com/quality".to_string()), + privacy_policy_url: Some("https://example.com/privacy".to_string()), + terms_of_service_url: Some("https://example.com/terms".to_string()), + default_prompt: Some(vec![ + "Review this change".to_string(), + "Find structural issues".to_string() + ]), + brand_color: Some("#00AAFF".to_string()), + composer_icon: Some( + AbsolutePathBuf::try_from(plugin_root.join("assets/icon.svg")).unwrap() + ), + logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + screenshots: vec![ + AbsolutePathBuf::try_from(plugin_root.join("assets/shot.png")).unwrap() + ], } ); + + let fallback_json: JsonValue = + serde_json::from_str(resolved.manifest_fallback.contents()).unwrap(); + assert_eq!( + fallback_json["skills"], + serde_json::json!([ + "./skills/thermo-nuclear-code-quality-review", + "./skills/second-review" + ]) + ); + assert_eq!( + fallback_json["mcpServers"], + serde_json::json!({ + "review": { + "type": "stdio", + "command": "review-mcp" + } + }) + ); + assert_eq!( + fallback_json["displayName"], + JsonValue::String("Quality Review".to_string()) + ); + assert_eq!( + fallback_json["interface"]["websiteUrl"], + JsonValue::String("https://example.com/quality".to_string()) + ); + assert_eq!( + fallback_json["interface"]["privacyPolicyURL"], + JsonValue::String("https://example.com/privacy".to_string()) + ); + assert!(fallback_json["interface"].get("privacyPolicyUrl").is_none()); + assert_eq!( + fallback_json["author"], + serde_json::json!({ "name": "Byron Grogan" }) + ); + assert_eq!( + fallback_json["agents"], + serde_json::json!(["./agents/thermo-nuclear-code-quality-review.md"]) + ); + assert_eq!( + fallback_json["commands"], + serde_json::json!(["./commands/review.md"]) + ); + assert_eq!(fallback_json["strict"], JsonValue::Bool(false)); + assert_eq!( + fallback_json["homepage"], + JsonValue::String("https://example.com/quality".to_string()) + ); + assert_eq!( + fallback_json["repository"], + JsonValue::String("https://github.com/example/quality-review".to_string()) + ); + assert_eq!( + fallback_json["license"], + JsonValue::String("MIT".to_string()) + ); + assert_eq!( + fallback_json["category"], + JsonValue::String("code-review".to_string()) + ); + assert!(resolved.manifest_fallback.has_metadata); } #[test] @@ -415,11 +637,99 @@ fn list_marketplaces_supports_alternate_manifest_layout() { screenshots: Vec::new(), }), keywords: Vec::new(), + manifest_fallback: None, }], }] ); } +#[test] +fn list_marketplaces_supports_repo_root_local_plugin_sources() { + for path in [".", "./"] { + 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::create_dir_all(repo_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "repo-root-marketplace", + "plugins": [ + {{ + "name": "repo-root-plugin", + "source": {{ + "source": "local", + "path": "{path}" + }} + }} + ] +}}"# + ), + ) + .unwrap(); + fs::write( + repo_root.join(".codex-plugin/plugin.json"), + r#"{ + "name":"repo-root-plugin", + "interface": { + "displayName": "Repo Root Plugin" + } +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "repo-root-marketplace".to_string(), + path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + interface: None, + plugins: vec![MarketplacePlugin { + name: "repo-root-plugin".to_string(), + local_version: None, + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: Some(PluginManifestInterface { + display_name: Some("Repo Root Plugin".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + }), + keywords: Vec::new(), + manifest_fallback: None, + }], + }] + ); + } +} + #[test] fn list_marketplaces_includes_plugins_without_discoverable_manifest() { let tmp = tempdir().unwrap(); @@ -466,6 +776,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -573,6 +884,7 @@ fn list_marketplaces_supports_explicit_api_marketplace_manifest_path() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -664,6 +976,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "home-only".to_string(), @@ -678,6 +991,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, ], }, @@ -701,6 +1015,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "repo-only".to_string(), @@ -715,6 +1030,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, ], }, @@ -794,6 +1110,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }, Marketplace { @@ -813,6 +1130,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }, ] @@ -888,6 +1206,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -1052,6 +1371,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -1134,6 +1454,9 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, { "name": "git-subdir-plugin", + "version": "1.2.3", + "displayName": "Git Subdir Plugin", + "keywords": ["git", "remote"], "source": { "source": "git-subdir", "url": "owner/repo", @@ -1154,8 +1477,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { .marketplaces; assert_eq!(marketplaces.len(), 1); + let mut plugins = marketplaces[0].plugins.clone(); + assert!(plugins[2].manifest_fallback.is_some()); + plugins[2].manifest_fallback = None; assert_eq!( - marketplaces[0].plugins, + plugins, vec![ MarketplacePlugin { name: "local-plugin".to_string(), @@ -1171,6 +1497,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "url-plugin".to_string(), @@ -1188,10 +1515,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "git-subdir-plugin".to_string(), - local_version: None, + local_version: Some("1.2.3".to_string()), source: MarketplacePluginSource::Git { url: "https://github.com/owner/repo.git".to_string(), path: Some("plugins/example".to_string()), @@ -1203,8 +1531,12 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { authentication: MarketplacePluginAuthPolicy::OnInstall, products: None, }, - interface: None, - keywords: Vec::new(), + interface: Some(PluginManifestInterface { + display_name: Some("Git Subdir Plugin".to_string()), + ..Default::default() + }), + keywords: vec!["git".to_string(), "remote".to_string()], + manifest_fallback: None, }, ] ); @@ -1423,37 +1755,41 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { #[test] fn find_marketplace_plugin_skips_invalid_local_paths() { - 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#"{ + for path in ["", "plugin-1", "././", "./plugins/../", "../plugin-1"] { + 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"), + format!( + r#"{{ "name": "codex-curated", "plugins": [ - { + {{ "name": "local-plugin", - "source": { + "source": {{ "source": "local", - "path": "../plugin-1" - } - } + "path": "{path}" + }} + }} ] -}"#, - ) - .unwrap(); +}}"# + ), + ) + .unwrap(); - let err = find_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), - "local-plugin", - ) - .unwrap_err(); + let err = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap_err(); - assert_eq!( - err.to_string(), - "plugin `local-plugin` was not found in marketplace `codex-curated`" - ); + assert_eq!( + err.to_string(), + "plugin `local-plugin` was not found in marketplace `codex-curated`" + ); + } } #[test] diff --git a/codex-rs/core-plugins/src/provider_tests.rs b/codex-rs/core-plugins/src/provider_tests.rs index a6a7e2fa7463..3f92a47f0b9f 100644 --- a/codex-rs/core-plugins/src/provider_tests.rs +++ b/codex-rs/core-plugins/src/provider_tests.rs @@ -8,6 +8,7 @@ use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::ExecutorFileSystemFuture; use codex_exec_server::FileMetadata; +use codex_exec_server::FileSystemReadStream; use codex_exec_server::FileSystemResult; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::LOCAL_ENVIRONMENT_ID; @@ -89,6 +90,14 @@ impl ExecutorFileSystem for SyntheticPluginFileSystem { }) } + fn read_file_stream<'a>( + &'a self, + _path: &'a PathUri, + _sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(async { Self::unsupported() }) + } + fn write_file<'a>( &'a self, _path: &'a PathUri, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 1a712c08a9a4..a0007aa1df0f 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -27,6 +27,7 @@ use std::fs; use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use tracing::instrument; use url::Url; mod catalog_cache; @@ -79,7 +80,11 @@ const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical"; const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku"; const CODEX_PRODUCT_SKU: &str = "codex"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); +const RECOMMENDED_PLUGINS_TIMEOUT: Duration = Duration::from_secs(5); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; +const MAX_RECOMMENDED_PLUGINS: usize = 50; +const MAX_RECOMMENDED_PLUGIN_NAME_LEN: usize = 64; +const MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN: usize = 64; const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; @@ -237,6 +242,20 @@ pub struct RemoteDiscoverablePlugin { pub availability: PluginAvailability, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecommendedPlugin { + pub config_id: String, + pub remote_plugin_id: String, + pub display_name: String, + pub app_connector_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecommendedPluginsMode { + Legacy, + Endpoint { plugins: Vec }, +} + pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool { !plugin_id.is_empty() && plugin_id @@ -568,6 +587,32 @@ struct RemotePluginListResponse { pagination: RemotePluginPagination, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RecommendedPluginsResponse { + #[serde(default)] + enabled: Option, + #[serde(default)] + plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RecommendedPluginItem { + id: String, + name: String, + #[serde(default)] + status: Option, + #[serde(default)] + installation_policy: Option, + release: RecommendedPluginRelease, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RecommendedPluginRelease { + display_name: String, + #[serde(default)] + app_ids: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginInstalledResponse { plugins: Vec, @@ -761,6 +806,87 @@ pub async fn fetch_and_cache_global_remote_plugin_catalog( Ok(()) } +#[instrument(level = "trace", skip_all)] +pub async fn fetch_recommended_plugins( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/suggested"); + let client = build_reqwest_client(); + let request = authenticated_request(client.get(&url), auth)? + .timeout(RECOMMENDED_PLUGINS_TIMEOUT) + .query(&[("scope", "GLOBAL")]); + let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?; + Ok(recommended_plugins_mode(response)) +} + +fn recommended_plugins_mode(response: RecommendedPluginsResponse) -> RecommendedPluginsMode { + if response.enabled != Some(true) { + return RecommendedPluginsMode::Legacy; + } + + let mut plugins = BTreeMap::new(); + for plugin in response.plugins { + if !is_valid_remote_plugin_id(&plugin.id) + || plugin.name.chars().count() > MAX_RECOMMENDED_PLUGIN_NAME_LEN + || plugin + .status + .is_some_and(|status| status != PluginAvailability::Available) + || plugin + .installation_policy + .is_some_and(|policy| policy != PluginInstallPolicy::Available) + { + continue; + } + let plugin_id = match PluginId::new( + plugin.name.clone(), + REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), + ) { + Ok(plugin_id) => plugin_id, + Err(err) => { + tracing::warn!( + plugin_name = plugin.name, + error = %err, + "ignoring invalid recommended plugin" + ); + continue; + } + }; + let RecommendedPluginRelease { + display_name, + app_ids, + } = plugin.release; + let display_name = non_empty_string(Some(&display_name)) + .unwrap_or_else(|| plugin.name.clone()) + .chars() + .take(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN) + .collect(); + let mut seen_app_ids = HashSet::new(); + let app_connector_ids = app_ids + .into_iter() + .filter(|app_id| !app_id.is_empty() && seen_app_ids.insert(app_id.clone())) + .collect(); + let config_id = plugin_id.as_key(); + plugins + .entry(config_id.clone()) + .or_insert(RecommendedPlugin { + config_id, + remote_plugin_id: plugin.id, + display_name, + app_connector_ids, + }); + } + + RecommendedPluginsMode::Endpoint { + plugins: plugins + .into_values() + .take(MAX_RECOMMENDED_PLUGINS) + .collect(), + } +} + pub fn has_cached_global_remote_plugin_catalog( codex_home: &Path, config: &RemotePluginServiceConfig, 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 8041d444b5d7..2a23b066edaa 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_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index cb60d60b77ee..66592dc363ef 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, }) @@ -293,13 +295,37 @@ async fn download_remote_plugin_bundle_with_limit( let url = final_url.to_string(); let status = response.status(); if !status.is_success() { - let body = read_response_body_with_limit( - response, - &url, - /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES, - ) - .await?; - let body = String::from_utf8_lossy(&body).to_string(); + let mut response = response; + let mut body = Vec::new(); + let mut body_truncated = false; + let mut body_read_error = None; + loop { + let chunk = match response.chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(source) => { + body_read_error = Some(source); + break; + } + }; + let remaining = REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES as usize - body.len(); + if chunk.len() > remaining { + body.extend_from_slice(&chunk[..remaining]); + body_truncated = true; + break; + } + body.extend_from_slice(&chunk); + } + + let mut body = String::from_utf8_lossy(&body).into_owned(); + if body_truncated { + body.push_str(&format!( + "\n[response body truncated after {REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES} bytes]" + )); + } + if let Some(source) = body_read_error { + body.push_str(&format!("\n[failed to read response body: {source}]")); + } return Err(RemotePluginBundleInstallError::DownloadStatus { url, status, body }); } @@ -379,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( @@ -721,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 cff134b80d01..793a8a55bd21 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -76,3 +76,206 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { }, } } +fn item(name: &str, display_name: &str) -> RecommendedPluginItem { + RecommendedPluginItem { + id: format!("plugin_{name}"), + name: name.to_string(), + status: None, + installation_policy: None, + release: RecommendedPluginRelease { + display_name: display_name.to_string(), + app_ids: Vec::new(), + }, + } +} + +#[test] +fn recommended_plugins_enabled_flag_selects_endpoint_or_legacy_mode() { + let disabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({ + "enabled": false, + "plugins": [{"id": "plugin_github", "name": "github", "release": {"display_name": "GitHub"}}] + })) + .expect("response should deserialize"); + assert_eq!( + recommended_plugins_mode(disabled), + RecommendedPluginsMode::Legacy + ); + + for response in [ + serde_json::json!({"plugins": []}), + serde_json::json!({"enabled": null, "plugins": []}), + ] { + let response: RecommendedPluginsResponse = + serde_json::from_value(response).expect("response should deserialize"); + assert_eq!( + recommended_plugins_mode(response), + RecommendedPluginsMode::Legacy + ); + } + + let enabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({ + "enabled": true, + "plugins": [] + })) + .expect("response should deserialize"); + assert_eq!( + recommended_plugins_mode(enabled), + RecommendedPluginsMode::Endpoint { + plugins: Vec::new() + } + ); +} + +#[test] +fn recommended_plugins_require_remote_install_identity() { + let response = serde_json::from_value::(serde_json::json!({ + "enabled": true, + "plugins": [{ + "name": "github", + "release": {"display_name": "GitHub"} + }] + })); + + assert!(response.is_err()); +} + +#[test] +fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() { + let mut plugins = (0..=52) + .rev() + .map(|index| item(&format!("plugin-{index:02}"), &format!("Plugin {index:02}"))) + .collect::>(); + plugins.push(item("plugin-00", "Duplicate")); + plugins.push(item("not/a/plugin", "Invalid")); + plugins.push(RecommendedPluginItem { + id: "plugin_disabled".to_string(), + name: "disabled".to_string(), + status: Some(PluginAvailability::DisabledByAdmin), + installation_policy: Some(PluginInstallPolicy::Available), + release: RecommendedPluginRelease { + display_name: "Disabled".to_string(), + app_ids: Vec::new(), + }, + }); + plugins.push(RecommendedPluginItem { + id: "plugin_not_available".to_string(), + name: "not-available".to_string(), + status: Some(PluginAvailability::Available), + installation_policy: Some(PluginInstallPolicy::NotAvailable), + release: RecommendedPluginRelease { + display_name: "Not Available".to_string(), + app_ids: Vec::new(), + }, + }); + + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins, + }); + let RecommendedPluginsMode::Endpoint { plugins } = mode else { + panic!("expected endpoint mode"); + }; + + assert_eq!(plugins.len(), MAX_RECOMMENDED_PLUGINS); + assert_eq!( + plugins.first(), + Some(&RecommendedPlugin { + config_id: "plugin-00@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_plugin-00".to_string(), + display_name: "Plugin 00".to_string(), + app_connector_ids: Vec::new(), + }) + ); + assert_eq!( + plugins.last(), + Some(&RecommendedPlugin { + config_id: "plugin-49@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_plugin-49".to_string(), + display_name: "Plugin 49".to_string(), + app_connector_ids: Vec::new(), + }) + ); +} + +#[test] +fn recommended_plugins_bound_model_visible_fields() { + let overlong_name = "n".repeat(MAX_RECOMMENDED_PLUGIN_NAME_LEN + 1); + let overlong_display_name = "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN + 1); + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins: vec![ + item(&overlong_name, "Ignored"), + item("bounded", &overlong_display_name), + ], + }); + + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: vec![RecommendedPlugin { + config_id: "bounded@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_bounded".to_string(), + display_name: "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN), + app_connector_ids: Vec::new(), + }], + } + ); +} + +#[test] +fn recommended_plugins_preserve_install_identity_and_normalize_app_ids() { + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins: vec![RecommendedPluginItem { + id: "plugin_connector_sample".to_string(), + name: "sample".to_string(), + status: Some(PluginAvailability::Available), + installation_policy: Some(PluginInstallPolicy::Available), + release: RecommendedPluginRelease { + display_name: "Sample".to_string(), + app_ids: vec![ + "connector_one".to_string(), + String::new(), + "connector_two".to_string(), + "connector_one".to_string(), + ], + }, + }], + }); + + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: vec![RecommendedPlugin { + config_id: "sample@openai-curated-remote".to_string(), + remote_plugin_id: "plugin_connector_sample".to_string(), + display_name: "Sample".to_string(), + app_connector_ids: vec!["connector_one".to_string(), "connector_two".to_string(),], + }], + } + ); +} + +#[test] +fn recommended_plugins_ignore_invalid_remote_plugin_ids() { + let mode = recommended_plugins_mode(RecommendedPluginsResponse { + enabled: Some(true), + plugins: vec![RecommendedPluginItem { + id: "not/a/plugin".to_string(), + name: "sample".to_string(), + status: None, + installation_policy: None, + release: RecommendedPluginRelease { + display_name: "Sample".to_string(), + app_ids: Vec::new(), + }, + }], + }); + + assert_eq!( + mode, + RecommendedPluginsMode::Endpoint { + plugins: Vec::new(), + } + ); +} diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 19c3b0116f31..5d78fefa90c3 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -1,21 +1,32 @@ use crate::manifest::PluginManifest; use crate::manifest::load_plugin_manifest; +use crate::manifest::parse_plugin_manifest; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; 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 { @@ -30,6 +41,12 @@ pub struct PluginStore { data_root: AbsolutePathBuf, } +#[derive(Clone, Copy)] +enum InstallManifest<'a> { + OnDisk, + Fallback(&'a str), +} + impl PluginStore { pub fn new(codex_home: PathBuf) -> Self { Self::try_new(codex_home) @@ -99,13 +116,121 @@ 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, plugin_id: PluginId, ) -> Result { - let plugin_version = plugin_version_for_source(source_path.as_path())?; - self.install_with_version(source_path, plugin_id, plugin_version) + self.install_with_manifest(source_path, plugin_id, InstallManifest::OnDisk) + } + + pub(crate) fn install_with_fallback_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + manifest_contents: &str, + ) -> Result { + self.install_with_manifest( + source_path, + plugin_id, + InstallManifest::Fallback(manifest_contents), + ) } pub fn install_with_version( @@ -113,6 +238,47 @@ impl PluginStore { source_path: AbsolutePathBuf, plugin_id: PluginId, plugin_version: String, + ) -> Result { + self.install_with_version_and_manifest( + source_path, + plugin_id, + plugin_version, + InstallManifest::OnDisk, + ) + } + + pub(crate) fn install_with_version_and_fallback_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + plugin_version: String, + manifest_contents: &str, + ) -> Result { + self.install_with_version_and_manifest( + source_path, + plugin_id, + plugin_version, + InstallManifest::Fallback(manifest_contents), + ) + } + + fn install_with_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + manifest: InstallManifest<'_>, + ) -> Result { + let manifest = resolve_install_manifest(source_path.as_path(), manifest); + let plugin_version = plugin_version_for_install_manifest(source_path.as_path(), manifest)?; + self.install_with_version_and_manifest(source_path, plugin_id, plugin_version, manifest) + } + + fn install_with_version_and_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + plugin_version: String, + manifest: InstallManifest<'_>, ) -> Result { if !source_path.as_path().is_dir() { return Err(PluginStoreError::Invalid(format!( @@ -121,7 +287,8 @@ impl PluginStore { ))); } - let plugin_name = plugin_name_for_source(source_path.as_path())?; + let manifest = resolve_install_manifest(source_path.as_path(), manifest); + let plugin_name = plugin_name_for_source(source_path.as_path(), manifest)?; if plugin_name != plugin_id.plugin_name { return Err(PluginStoreError::Invalid(format!( "plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`", @@ -134,7 +301,9 @@ impl PluginStore { source_path.as_path(), self.plugin_base_root(&plugin_id).as_path(), &plugin_version, + manifest, )?; + self.remove_remote_plugin_install_metadata(&plugin_id)?; Ok(PluginInstallResult { plugin_id, @@ -146,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)] @@ -168,7 +357,37 @@ impl PluginStoreError { } pub fn plugin_version_for_source(source_path: &Path) -> Result { - let plugin_version = plugin_manifest_version_for_source(source_path)? + plugin_version_for_install_manifest(source_path, InstallManifest::OnDisk) +} + +pub(crate) fn plugin_version_for_source_with_fallback_manifest( + source_path: &Path, + manifest_contents: &str, +) -> Result { + let manifest = + resolve_install_manifest(source_path, InstallManifest::Fallback(manifest_contents)); + plugin_version_for_install_manifest(source_path, manifest) +} + +fn resolve_install_manifest<'a>( + source_path: &Path, + manifest: InstallManifest<'a>, +) -> InstallManifest<'a> { + // A real plugin manifest always wins. The fallback only fills the gap for marketplace + // sources that cannot be changed in place because they may be user-owned directories. + match manifest { + InstallManifest::Fallback(_) if find_plugin_manifest_path(source_path).is_some() => { + InstallManifest::OnDisk + } + manifest => manifest, + } +} + +fn plugin_version_for_install_manifest( + source_path: &Path, + manifest: InstallManifest<'_>, +) -> Result { + let plugin_version = plugin_manifest_version_for_source(source_path, manifest)? .unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string()); validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?; Ok(plugin_version) @@ -193,9 +412,20 @@ pub fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), Strin Ok(()) } -fn plugin_manifest_for_source(source_path: &Path) -> Result { - load_plugin_manifest(source_path) - .ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string())) +fn plugin_manifest_for_source( + source_path: &Path, + manifest: InstallManifest<'_>, +) -> Result { + match manifest { + InstallManifest::OnDisk => load_plugin_manifest(source_path) + .ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string())), + InstallManifest::Fallback(contents) => parse_plugin_manifest( + source_path, + &source_path.join(".codex-plugin/plugin.json"), + contents, + ) + .map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}"))), + } } #[derive(Debug, Deserialize)] @@ -207,12 +437,17 @@ struct RawPluginManifestVersion { fn plugin_manifest_version_for_source( source_path: &Path, + manifest: InstallManifest<'_>, ) -> Result, PluginStoreError> { - let manifest_path = find_plugin_manifest_path(source_path) - .ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?; - - let contents = fs::read_to_string(&manifest_path) - .map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?; + let contents = match manifest { + InstallManifest::OnDisk => { + let manifest_path = find_plugin_manifest_path(source_path) + .ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?; + fs::read_to_string(&manifest_path) + .map_err(|err| PluginStoreError::io("failed to read plugin.json", err))? + } + InstallManifest::Fallback(contents) => contents.to_string(), + }; let manifest: RawPluginManifestVersion = serde_json::from_str(&contents) .map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?; let Some(version) = manifest.version else { @@ -232,8 +467,11 @@ fn plugin_manifest_version_for_source( Ok(Some(version.to_string())) } -fn plugin_name_for_source(source_path: &Path) -> Result { - let manifest = plugin_manifest_for_source(source_path)?; +fn plugin_name_for_source( + source_path: &Path, + manifest: InstallManifest<'_>, +) -> Result { + let manifest = plugin_manifest_for_source(source_path, manifest)?; let plugin_name = manifest.name; validate_plugin_segment(&plugin_name, "plugin name") @@ -261,6 +499,7 @@ fn replace_plugin_root_atomically( source: &Path, target_root: &Path, plugin_version: &str, + manifest: InstallManifest<'_>, ) -> Result<(), PluginStoreError> { let Some(parent) = target_root.parent() else { return Err(PluginStoreError::Invalid(format!( @@ -287,6 +526,21 @@ fn replace_plugin_root_atomically( let staged_root = staged_dir.path().join(plugin_dir_name); let staged_version_root = staged_root.join(plugin_version); copy_dir_recursive(source, &staged_version_root)?; + if let InstallManifest::Fallback(contents) = manifest { + // Inject the generated manifest into Store's existing atomic copy so install does not + // mutate the original source or require a second staging directory. + let manifest_path = staged_version_root.join(".codex-plugin/plugin.json"); + let Some(manifest_parent) = manifest_path.parent() else { + return Err(PluginStoreError::Invalid( + "plugin manifest path has no parent".to_string(), + )); + }; + fs::create_dir_all(manifest_parent).map_err(|err| { + PluginStoreError::io("failed to create plugin manifest directory", err) + })?; + fs::write(&manifest_path, contents) + .map_err(|err| PluginStoreError::io("failed to write fallback plugin manifest", err))?; + } let target_version_root = target_root.join(plugin_version); if target_root.exists() && !target_version_root.exists() { diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 200055fe6cb3..237fb4e88a6d 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( @@ -71,6 +72,46 @@ fn install_copies_plugin_into_default_marketplace() { assert!(installed_path.join("skills/SKILL.md").is_file()); } +#[test] +fn install_accepts_manifest_mcp_server_objects() { + let tmp = tempdir().unwrap(); + let plugin_root = tmp.path().join("counter-sample"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "counter-sample", + "version": "1.1.1", + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +}"#, + ) + .unwrap(); + let plugin_id = PluginId::new("counter-sample".to_string(), "debug".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(plugin_root).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/counter-sample/1.1.1"); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "1.1.1".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + #[test] fn install_uses_manifest_name_for_destination_and_key() { let tmp = tempdir().unwrap(); @@ -152,7 +193,112 @@ fn install_with_version_uses_requested_cache_version() { } #[test] -fn install_uses_manifest_version_when_present() { +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(); write_plugin_with_version( tmp.path(), @@ -163,9 +309,10 @@ fn install_uses_manifest_version_when_present() { let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); let result = PluginStore::new(tmp.path().to_path_buf()) - .install( + .install_with_fallback_manifest( AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), plugin_id.clone(), + r#"{"name":"sample-plugin","version":"9.9.9"}"#, ) .unwrap(); diff --git a/codex-rs/core-plugins/src/tool_suggest_metadata.rs b/codex-rs/core-plugins/src/tool_suggest_metadata.rs new file mode 100644 index 000000000000..310bec865449 --- /dev/null +++ b/codex-rs/core-plugins/src/tool_suggest_metadata.rs @@ -0,0 +1,238 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::RwLock; + +use codex_app_server_protocol::AuthMode; +use codex_core_skills::config_rules::SkillConfigRules; +use codex_plugin::AppDeclaration; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_plugin::app_connector_ids_from_declarations; +use codex_plugin::prompt_safe_plugin_description; +use codex_protocol::protocol::Product; +use tokio::sync::Semaphore; + +use crate::app_mcp_routing::apply_app_mcp_routing_policy; +use crate::loader::PluginSkillInventory; +use crate::loader::load_plugin_apps; +use crate::loader::load_plugin_mcp_servers; +use crate::loader::load_plugin_skill_inventory; +use crate::manager::ConfiguredMarketplacePlugin; +use crate::manager::remote_plugin_install_required_description; +use crate::manifest::load_plugin_manifest; +use crate::marketplace::MarketplaceError; +use crate::marketplace::MarketplacePluginSource; + +const MAX_TOOL_SUGGEST_METADATA_CACHE_ENTRIES: usize = 1024; + +type ToolSuggestMetadataEntry = Result, String>; + +/// Source-derived plugin metadata cached for tool suggestions. +/// +/// `PluginsManager` clears these entries alongside its loaded-plugin cache. Current skill config +/// and auth routing are projected after each lookup and are not part of this cache. +pub(crate) struct ToolSuggestMetadataCache { + state: RwLock, + load_semaphore: Semaphore, +} + +#[derive(Default)] +struct ToolSuggestMetadataCacheState { + generation: u64, + entries: HashMap, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +struct PluginArtifactIdentity { + plugin_id: String, + source: MarketplacePluginSource, +} + +pub(crate) struct ToolSuggestMetadataFragment { + config_name: String, + display_name: String, + description: Option, + mcp_server_names: Vec, + app_declarations: Vec, + skill_inventory: Option, +} + +impl ToolSuggestMetadataFragment { + pub(crate) fn project( + &self, + skill_config_rules: &SkillConfigRules, + auth_mode: Option, + ) -> PluginCapabilitySummary { + let mut app_declarations = self.app_declarations.clone(); + let mut mcp_servers = self + .mcp_server_names + .iter() + .cloned() + .map(|name| (name, ())) + .collect::>(); + if auth_mode.is_some() { + apply_app_mcp_routing_policy( + &mut app_declarations, + &mut mcp_servers, + auth_mode, + /*plugin_active*/ true, + ); + } + let mut mcp_server_names = mcp_servers.into_keys().collect::>(); + mcp_server_names.sort_unstable(); + + PluginCapabilitySummary { + config_name: self.config_name.clone(), + display_name: self.display_name.clone(), + description: self.description.clone(), + has_skills: self + .skill_inventory + .as_ref() + .is_some_and(|inventory| inventory.has_enabled_skills(skill_config_rules)), + mcp_server_names, + app_connector_ids: app_connector_ids_from_declarations(&app_declarations), + } + } +} + +impl ToolSuggestMetadataCache { + pub(crate) fn new() -> Self { + Self { + state: RwLock::new(ToolSuggestMetadataCacheState::default()), + load_semaphore: Semaphore::new(/*permits*/ 1), + } + } + + pub(crate) fn clear(&self) { + let mut state = match self.state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.generation = state.generation.wrapping_add(1); + state.entries.clear(); + } + + pub(crate) async fn metadata_for_plugin( + &self, + marketplace_name: &str, + plugin: &ConfiguredMarketplacePlugin, + restriction_product: Option, + ) -> Result, MarketplaceError> { + let artifact = PluginArtifactIdentity { + plugin_id: plugin.id.clone(), + source: plugin.source.clone(), + }; + loop { + if let Some(entry) = self.cached_entry(&artifact) { + return entry.map_err(MarketplaceError::InvalidPlugin); + } + + let _load_permit = self.load_semaphore.acquire().await.map_err(|_| { + MarketplaceError::InvalidPlugin( + "tool-suggest metadata cache loader closed".to_string(), + ) + })?; + if let Some(entry) = self.cached_entry(&artifact) { + return entry.map_err(MarketplaceError::InvalidPlugin); + } + + let generation = self.generation(); + let entry = load_plugin_metadata(marketplace_name, plugin, restriction_product).await; + if self.cache_entry_if_current(generation, artifact.clone(), entry.clone()) { + return entry.map_err(MarketplaceError::InvalidPlugin); + } + } + } + + fn cached_entry(&self, artifact: &PluginArtifactIdentity) -> Option { + match self.state.read() { + Ok(state) => state.entries.get(artifact).cloned(), + Err(err) => err.into_inner().entries.get(artifact).cloned(), + } + } + + fn generation(&self) -> u64 { + match self.state.read() { + Ok(state) => state.generation, + Err(err) => err.into_inner().generation, + } + } + + fn cache_entry_if_current( + &self, + generation: u64, + artifact: PluginArtifactIdentity, + entry: ToolSuggestMetadataEntry, + ) -> bool { + let mut state = match self.state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + if state.generation != generation { + return false; + } + if state.entries.len() >= MAX_TOOL_SUGGEST_METADATA_CACHE_ENTRIES + && !state.entries.contains_key(&artifact) + { + state.entries.clear(); + } + state.entries.insert(artifact, entry); + true + } +} + +async fn load_plugin_metadata( + marketplace_name: &str, + plugin: &ConfiguredMarketplacePlugin, + restriction_product: Option, +) -> ToolSuggestMetadataEntry { + let plugin_id = PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err( + |err| match err { + PluginIdError::Invalid(message) => message, + }, + )?; + + let MarketplacePluginSource::Local { path: plugin_root } = &plugin.source else { + return Ok(Arc::new(ToolSuggestMetadataFragment { + config_name: plugin.id.clone(), + display_name: plugin.name.clone(), + description: prompt_safe_plugin_description(Some( + &remote_plugin_install_required_description(&plugin.source), + )), + mcp_server_names: Vec::new(), + app_declarations: Vec::new(), + skill_inventory: None, + })); + }; + if !plugin_root.as_path().is_dir() { + return Err("path does not exist or is not a directory".to_string()); + } + let manifest = load_plugin_manifest(plugin_root.as_path()) + .ok_or_else(|| "missing or invalid plugin.json".to_string())?; + let skill_inventory = load_plugin_skill_inventory( + plugin_root, + &plugin_id, + &manifest, + restriction_product, + /*plugin_skill_snapshots*/ None, + ) + .await; + let mut mcp_server_names = + load_plugin_mcp_servers(plugin_root.as_path(), /*auth_mode*/ None) + .await + .into_keys() + .collect::>(); + mcp_server_names.sort_unstable(); + mcp_server_names.dedup(); + let app_declarations = load_plugin_apps(plugin_root.as_path()).await; + + Ok(Arc::new(ToolSuggestMetadataFragment { + config_name: plugin.id.clone(), + display_name: plugin.name.clone(), + description: prompt_safe_plugin_description(manifest.description.as_deref()), + mcp_server_names, + app_declarations, + skill_inventory: Some(skill_inventory), + })) +} diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index 7748540ac18a..3ac82be9a8f8 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/injection.rs b/codex-rs/core-skills/src/injection.rs index e7d1fd374941..201e7959fbd5 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -55,6 +55,11 @@ impl InjectedHostSkillPrompts { } } +#[tracing::instrument( + level = "trace", + skip_all, + fields(mentioned_skill_count = mentioned_skills.len()) +)] pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], loaded_skills: Option<&SkillLoadOutcome>, diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index 0390302afdfe..53ffcddd0752 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -2,20 +2,19 @@ pub mod config_rules; pub mod injection; pub(crate) mod invocation_utils; pub mod loader; -pub mod manager; mod mention_counts; pub mod model; pub mod remote; pub mod render; +mod root_loader; +pub mod service; mod skill_instructions; pub mod system; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; pub use invocation_utils::detect_implicit_skill_invocation_for_command; -pub use manager::SkillsLoadInput; -pub use manager::SkillsManager; pub use mention_counts::build_skill_name_counts; -pub use model::HostLoadedSkills; +pub use model::HostSkillsSnapshot; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; @@ -31,4 +30,7 @@ pub use render::SkillRenderReport; pub use render::build_available_skills; pub use render::default_skill_metadata_budget; pub use render::render_available_skills_body; +pub use root_loader::PluginSkillSnapshots; +pub use service::SkillsLoadInput; +pub use service::SkillsService; pub use skill_instructions::SkillInstructions; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 3b59e908d6d6..b377e909ead9 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -1,6 +1,5 @@ use crate::model::SkillDependencies; use crate::model::SkillError; -use crate::model::SkillFileSystemsByPath; use crate::model::SkillInterface; use crate::model::SkillLoadOutcome; use crate::model::SkillMetadata; @@ -23,8 +22,8 @@ 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::HashMap; use std::collections::HashSet; use std::collections::VecDeque; use std::error::Error; @@ -157,79 +156,55 @@ pub struct SkillRoot { pub scope: SkillScope, pub file_system: Arc, pub plugin_id: Option, + pub plugin_namespace: Option, pub plugin_root: Option, } -pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome +pub async fn load_skills_from_roots( + roots: I, + plugin_skill_snapshots: Option<&crate::PluginSkillSnapshots>, +) -> SkillLoadOutcome where I: IntoIterator, { - let mut outcome = SkillLoadOutcome::default(); - let mut skill_roots: Vec = Vec::new(); - let mut skill_root_by_path: HashMap = HashMap::new(); - let mut file_systems_by_skill_path: HashMap> = - HashMap::new(); - for root in roots { - let fs = root.file_system; - let root_path = canonicalize_for_skill_identity(fs.as_ref(), &root.path).await; - let skills_before_root = outcome.skills.len(); - discover_skills_under_root( - fs.as_ref(), - &root_path, - root.scope, - root.plugin_id.as_deref(), - root.plugin_root.as_ref(), - &mut outcome, - ) - .await; - for skill in &outcome.skills[skills_before_root..] { - if !skill_roots.contains(&root_path) { - skill_roots.push(root_path.clone()); - } - skill_root_by_path - .entry(skill.path_to_skills_md.clone()) - .or_insert_with(|| root_path.clone()); - file_systems_by_skill_path - .entry(skill.path_to_skills_md.clone()) - .or_insert_with(|| Arc::clone(&fs)); - } - } - - let mut seen: HashSet = HashSet::new(); - outcome - .skills - .retain(|skill| seen.insert(skill.path_to_skills_md.clone())); - let retained_skill_paths: HashSet = outcome - .skills - .iter() - .map(|skill| skill.path_to_skills_md.clone()) - .collect(); - skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); - let used_roots: HashSet = skill_root_by_path.values().cloned().collect(); - skill_roots.retain(|root| used_roots.contains(root)); - file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); - outcome.skill_roots = skill_roots; - outcome.skill_root_by_path = Arc::new(skill_root_by_path); - outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); - - fn scope_rank(scope: SkillScope) -> u8 { - // Higher-priority scopes first (matches root scan order for dedupe). - match scope { - SkillScope::Repo => 0, - SkillScope::User => 1, - SkillScope::System => 2, - SkillScope::Admin => 3, - } - } + crate::root_loader::load_and_merge_skill_roots(roots, plugin_skill_snapshots).await +} - outcome.skills.sort_by(|a, b| { - scope_rank(a.scope) - .cmp(&scope_rank(b.scope)) - .then_with(|| a.name.cmp(&b.name)) - .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) - }); +#[derive(Clone)] +pub(crate) struct SkillRootSnapshot { + pub(crate) root: AbsolutePathBuf, + pub(crate) skills: Vec, + pub(crate) errors: Vec, + pub(crate) file_system: Arc, +} - outcome +pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot { + let SkillRoot { + path, + scope, + file_system, + plugin_id, + plugin_namespace, + plugin_root, + } = root; + let root = canonicalize_for_skill_identity(file_system.as_ref(), &path).await; + let mut outcome = SkillLoadOutcome::default(); + discover_skills_under_root( + file_system.as_ref(), + &root, + scope, + plugin_id.as_deref(), + plugin_namespace.as_deref(), + plugin_root.as_ref(), + &mut outcome, + ) + .await; + SkillRootSnapshot { + root, + skills: outcome.skills, + errors: outcome.errors, + file_system, + } } pub(crate) async fn skill_roots( @@ -266,6 +241,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(root.plugin_id), + plugin_namespace: Some(root.plugin_namespace), plugin_root: Some(root.plugin_root), })); roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot { @@ -273,6 +249,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); @@ -303,6 +280,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -315,6 +293,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); @@ -325,6 +304,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -336,6 +316,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -347,6 +328,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -382,6 +364,7 @@ async fn repo_agents_skill_roots( scope: SkillScope::Repo, file_system: Arc::clone(&fs), plugin_id: None, + plugin_namespace: None, plugin_root: None, }), Ok(_) => {} @@ -490,6 +473,7 @@ async fn discover_skills_under_root( root: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_namespace: Option<&str>, plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome, ) { @@ -551,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()); @@ -610,7 +608,16 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await { + match parse_skill_file( + fs, + &path, + scope, + plugin_id, + plugin_namespace, + plugin_root.as_ref(), + ) + .await + { Ok(skill) => { outcome.skills.push(skill); } @@ -641,6 +648,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_namespace: Option<&str>, plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let path_uri = PathUri::from_abs_path(path); @@ -651,8 +659,19 @@ async fn parse_skill_file( let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?; - let parsed: SkillFrontmatter = - serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?; + let parsed: SkillFrontmatter = match serde_yaml::from_str(&frontmatter) { + Ok(parsed) => Ok(parsed), + Err(original_error) => match repair_frontmatter_scalar_fields(&frontmatter) { + // Some third-party skills use prose like `description: Build for AWS: ECS` + // or `argument-hint: `. Keep the repair line-oriented + // so unrelated invalid YAML still surfaces. + Some(repaired_frontmatter) => { + serde_yaml::from_str(&repaired_frontmatter).map_err(|_| original_error) + } + None => Err(original_error), + }, + } + .map_err(SkillParseError::InvalidYaml)?; let base_name = parsed .name @@ -660,7 +679,7 @@ async fn parse_skill_file( .map(sanitize_single_line) .filter(|value| !value.is_empty()) .unwrap_or_else(|| default_skill_name(path)); - let name = namespaced_skill_name(fs, path, &base_name).await; + let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await; let description = parsed .description .as_deref() @@ -680,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; @@ -720,7 +734,11 @@ async fn namespaced_skill_name( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, base_name: &str, + plugin_namespace: Option<&str>, ) -> String { + if let Some(plugin_namespace) = plugin_namespace { + return format!("{plugin_namespace}:{base_name}"); + } plugin_namespace_for_skill_path(fs, path) .await .map(|namespace| format!("{namespace}:{base_name}")) @@ -997,6 +1015,91 @@ fn sanitize_single_line(raw: &str) -> String { raw.split_whitespace().collect::>().join(" ") } +fn repair_frontmatter_scalar_fields(frontmatter: &str) -> Option { + let mut changed = false; + let mut block_scalar_indent: Option = None; + let mut repaired_lines: Vec = Vec::new(); + for line in frontmatter.lines() { + let indent = line + .chars() + .take_while(|character| *character == ' ') + .count(); + if let Some(block_indent) = block_scalar_indent { + if line.trim().is_empty() || indent > block_indent { + repaired_lines.push(line.to_string()); + continue; + } + block_scalar_indent = None; + } + + let Some((key, value)) = line.split_once(':') else { + repaired_lines.push(line.to_string()); + continue; + }; + if key.trim().is_empty() || !value.chars().next().is_none_or(char::is_whitespace) { + repaired_lines.push(line.to_string()); + continue; + } + + let trimmed_start = value.trim_start(); + let leading_whitespace = &value[..value.len() - trimmed_start.len()]; + let mut scalar = trimmed_start; + let mut comment = ""; + for (index, character) in trimmed_start.char_indices() { + if character == '#' + && (index == 0 + || trimmed_start[..index] + .chars() + .next_back() + .is_some_and(char::is_whitespace)) + { + let comment_start = trimmed_start[..index].trim_end().len(); + scalar = &trimmed_start[..comment_start]; + comment = &trimmed_start[comment_start..]; + break; + } + } + + let scalar = scalar.trim_end(); + let Some(first_char) = scalar.chars().next() else { + repaired_lines.push(line.to_string()); + continue; + }; + if matches!(first_char, '|' | '>') { + block_scalar_indent = Some(indent); + repaired_lines.push(line.to_string()); + continue; + } + if matches!(first_char, '\'' | '"') { + repaired_lines.push(line.to_string()); + continue; + } + let mut has_colon_separator = false; + let mut chars = scalar.chars().peekable(); + while let Some(character) = chars.next() { + if character == ':' + && matches!(chars.peek(), Some(next_character) if next_character.is_whitespace()) + { + has_colon_separator = true; + break; + } + } + let invalid_flow_like_scalar = matches!(first_char, '[' | '{' | '@' | '`') + && serde_yaml::from_str::(scalar).is_err(); + if !has_colon_separator && !invalid_flow_like_scalar { + repaired_lines.push(line.to_string()); + continue; + } + + let quoted_scalar = format!("'{}'", scalar.replace('\'', "''")); + repaired_lines.push(format!( + "{key}:{leading_whitespace}{quoted_scalar}{comment}" + )); + changed = true; + } + changed.then(|| repaired_lines.join("\n")) +} + fn validate_len( value: &str, max_len: usize, diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 7f129556f603..0b00ce710d15 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -152,6 +152,7 @@ async fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome { /*home_dir*/ None, ) .await, + /*plugin_skill_snapshots*/ None, ) .await } @@ -339,7 +340,7 @@ async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<() Some(&home_folder_abs), ) .await; - let outcome = load_skills_from_roots(roots).await; + let outcome = load_skills_from_roots(roots, /*plugin_skill_snapshots*/ None).await; assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -869,13 +870,17 @@ interface: ); let plugin_root_abs = plugin_root.abs(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("twilio-developer-kit@test".to_string()), - plugin_root: Some(plugin_root_abs.clone()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, + plugin_root: Some(plugin_root_abs.clone()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -926,13 +931,17 @@ interface: "##, ); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("twilio-developer-kit@test".to_string()), - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1072,13 +1081,17 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { fs::create_dir_all(admin_root.path()).unwrap(); symlink_dir(shared.path(), &admin_root.path().join("shared")); - let outcome = load_skills_from_roots([SkillRoot { - path: admin_root.path().abs(), - scope: SkillScope::Admin, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: admin_root.path().abs(), + scope: SkillScope::Admin, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1154,13 +1167,17 @@ async fn system_scope_ignores_symlinked_subdir() { fs::create_dir_all(&system_root).unwrap(); symlink_dir(shared.path(), &system_root.join("shared")); - let outcome = load_skills_from_roots([SkillRoot { - path: system_root.abs(), - scope: SkillScope::System, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: system_root.abs(), + scope: SkillScope::System, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( outcome.errors.is_empty(), @@ -1188,13 +1205,17 @@ async fn respects_max_scan_depth_for_user_scope() { ); let skills_root = codex_home.path().join("skills"); - let outcome = load_skills_from_roots([SkillRoot { - path: skills_root.abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: skills_root.abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1280,7 +1301,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { } #[tokio::test] -async fn namespaces_plugin_skills_using_plugin_name() { +async fn namespaces_plugin_skills_using_provided_namespace() { let root = tempfile::tempdir().expect("tempdir"); let plugin_root = root.path().join("plugins/sample"); let skill_path = write_raw_skill_at( @@ -1291,17 +1312,21 @@ async fn namespaces_plugin_skills_using_plugin_name() { fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, + r#"{"name":"should-not-be-read"}"#, ) .unwrap(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("sample@test".to_string()), - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some("sample".to_string()), + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1340,13 +1365,17 @@ async fn plugin_skill_name_length_limit_allows_max_qualified_name() { ) .unwrap(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("sample@test".to_string()), - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1385,13 +1414,17 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { ) .unwrap(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("sample@test".to_string()), - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert_eq!(outcome.skills, Vec::new()); @@ -1436,7 +1469,135 @@ async fn loads_short_description_from_metadata() { } #[tokio::test] -async fn enforces_short_description_length_limits() { +async fn loads_unquoted_description_containing_colon_space() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "colon-description", + "name: colon-description\ndescription: AWS deployment patterns: ECS Fargate, Lambda, and S3", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "colon-description".to_string(), + description: "AWS deployment patterns: ECS Fargate, Lambda, and S3".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_unquoted_short_description_containing_colon_space_and_apostrophe() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "colon-short-description", + "name: colon-short-description\ndescription: long description\nmetadata:\n short-description: What's included: builds and tests", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "colon-short-description".to_string(), + description: "long description".to_string(), + short_description: Some("What's included: builds and tests".to_string()), + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_unrecognized_frontmatter_fields_that_need_quotes() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "repaired-unknown-fields", + "name: repaired-unknown-fields\ndescription: valid description\nargument-hint: \ntags: [next,@supabase/ssr]", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repaired-unknown-fields".to_string(), + description: "valid description".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn preserves_block_scalar_body_while_repairing_other_fields() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "block-description-with-repair", + "name: block-description-with-repair\ndescription: |-\n Build for AWS: ECS\nargument-hint: ", + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "block-description-with-repair".to_string(), + description: "Build for AWS: ECS".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +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(); @@ -1448,15 +1609,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] @@ -1488,7 +1647,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); @@ -1505,12 +1664,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] @@ -1695,22 +1860,27 @@ async fn deduplicates_by_path_preferring_first_root() { let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo"); - let outcome = load_skills_from_roots([ - SkillRoot { - path: root.path().abs(), - scope: SkillScope::Repo, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_root: None, - }, - SkillRoot { - path: root.path().abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_root: None, - }, - ]) + let outcome = load_skills_from_roots( + [ + SkillRoot { + path: root.path().abs(), + scope: SkillScope::Repo, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }, + SkillRoot { + path: root.path().abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }, + ], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index 3abb30c96d0c..6afbde6cc94b 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -132,14 +132,14 @@ impl SkillLoadOutcome { } } -/// Host-loaded skills for one turn, including the filesystem mapping needed to -/// read skill bodies through the environment that loaded them. +/// Immutable snapshot of host-owned skills and the filesystem mapping needed +/// to read each skill through the environment that discovered it. #[derive(Debug, Clone)] -pub struct HostLoadedSkills { +pub struct HostSkillsSnapshot { outcome: Arc, } -impl HostLoadedSkills { +impl HostSkillsSnapshot { pub fn new(outcome: Arc) -> Self { Self { outcome } } diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index f80b776c3ca8..6ae7ed86239e 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-skills/src/root_loader.rs b/codex-rs/core-skills/src/root_loader.rs new file mode 100644 index 000000000000..a31c8a275392 --- /dev/null +++ b/codex-rs/core-skills/src/root_loader.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt; +use std::sync::Arc; +use std::sync::Mutex; + +use codex_utils_plugins::PluginSkillRoot; + +use crate::SkillLoadOutcome; +use crate::loader::SkillRoot; +use crate::loader::SkillRootSnapshot; +use crate::loader::load_skill_root; +use crate::model::SkillFileSystemsByPath; + +/// Parsed plugin skill-root snapshots produced by one plugin load. +/// +/// Clones share the same snapshots. The plugins manager stores them with the corresponding loaded +/// plugins and passes a clone to skill loading as an optional preload. +#[derive(Clone)] +pub struct PluginSkillSnapshots { + snapshots_by_root: Arc>>, +} + +impl PluginSkillSnapshots { + /// Creates an empty snapshot collection for a plugin load to populate. + pub fn for_plugin_load() -> Self { + Self { + snapshots_by_root: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl fmt::Debug for PluginSkillSnapshots { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PluginSkillSnapshots") + .finish_non_exhaustive() + } +} + +pub(crate) async fn load_and_merge_skill_roots( + roots: I, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, +) -> SkillLoadOutcome +where + I: IntoIterator, +{ + let mut root_snapshots = Vec::new(); + for root in roots { + let cache_key = match ( + root.plugin_id.clone(), + root.plugin_namespace.clone(), + root.plugin_root.clone(), + ) { + (Some(plugin_id), Some(plugin_namespace), Some(plugin_root)) => Some(PluginSkillRoot { + path: root.path.clone(), + plugin_id, + plugin_namespace, + plugin_root, + }), + _ => None, + }; + let cached_snapshot = cache_key.as_ref().and_then(|cache_key| { + let plugin_skill_snapshots = plugin_skill_snapshots?; + plugin_skill_snapshots + .snapshots_by_root + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(cache_key) + .cloned() + }); + let snapshot = match cached_snapshot { + Some(snapshot) => snapshot, + None => { + let snapshot = load_skill_root(root).await; + if let Some(plugin_skill_snapshots) = plugin_skill_snapshots + && let Some(cache_key) = cache_key + { + plugin_skill_snapshots + .snapshots_by_root + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(cache_key, snapshot.clone()); + } + snapshot + } + }; + root_snapshots.push(snapshot); + } + + merge_skill_root_snapshots(root_snapshots) +} + +fn merge_skill_root_snapshots(snapshots: Vec) -> SkillLoadOutcome { + fn scope_rank(scope: codex_protocol::protocol::SkillScope) -> u8 { + use codex_protocol::protocol::SkillScope; + + // Higher-priority scopes first (matches root scan order for dedupe). + match scope { + SkillScope::Repo => 0, + SkillScope::User => 1, + SkillScope::System => 2, + SkillScope::Admin => 3, + } + } + + let mut outcome = SkillLoadOutcome::default(); + let mut skill_roots = Vec::new(); + let mut skill_root_by_path = HashMap::new(); + let mut file_systems_by_skill_path = HashMap::new(); + + for snapshot in snapshots { + let SkillRootSnapshot { + root, + skills, + errors, + file_system, + } = snapshot; + if !skills.is_empty() && !skill_roots.contains(&root) { + skill_roots.push(root.clone()); + } + for skill in &skills { + skill_root_by_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| root.clone()); + file_systems_by_skill_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| Arc::clone(&file_system)); + } + outcome.skills.extend(skills); + outcome.errors.extend(errors); + } + + let mut seen = HashSet::new(); + outcome + .skills + .retain(|skill| seen.insert(skill.path_to_skills_md.clone())); + let retained_skill_paths = outcome + .skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); + let used_roots = skill_root_by_path.values().cloned().collect::>(); + skill_roots.retain(|root| used_roots.contains(root)); + file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); + outcome.skill_roots = skill_roots; + outcome.skill_root_by_path = Arc::new(skill_root_by_path); + outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); + + outcome.skills.sort_by(|a, b| { + scope_rank(a.scope) + .cmp(&scope_rank(b.scope)) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) + }); + + outcome +} diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/service.rs similarity index 76% rename from codex-rs/core-skills/src/manager.rs rename to codex-rs/core-skills/src/service.rs index f8c5bfd041ec..1c956d79b90e 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/service.rs @@ -13,6 +13,8 @@ use tracing::info; use tracing::instrument; use tracing::warn; +use crate::HostSkillsSnapshot; +use crate::PluginSkillSnapshots; use crate::SkillLoadOutcome; use crate::build_implicit_skill_path_indexes; use crate::config_rules::SkillConfigRules; @@ -31,6 +33,7 @@ pub struct SkillsLoadInput { pub effective_skill_roots: Vec, pub config_layer_stack: ConfigLayerStack, pub bundled_skills_enabled: bool, + plugin_skill_snapshots: Option, } impl SkillsLoadInput { @@ -45,19 +48,32 @@ impl SkillsLoadInput { effective_skill_roots, config_layer_stack, bundled_skills_enabled, + plugin_skill_snapshots: None, } } + + /// Attaches plugin skill snapshots parsed during plugin loading, when available. + pub fn with_plugin_skill_snapshots( + mut self, + plugin_skill_snapshots: Option, + ) -> Self { + self.plugin_skill_snapshots = plugin_skill_snapshots; + self + } } -pub struct SkillsManager { +/// Owns host skill discovery, immutable snapshots, cache invalidation, and extra roots. +/// +/// Source-specific model exposure remains the responsibility of the skills extension. +pub struct SkillsService { codex_home: AbsolutePathBuf, restriction_product: Option, extra_roots: RwLock>, - cache_by_cwd: RwLock>, - cache_by_config: RwLock>, + cache_by_cwd: RwLock>, + cache_by_config: RwLock>, } -impl SkillsManager { +impl SkillsService { pub fn new(codex_home: AbsolutePathBuf, bundled_skills_enabled: bool) -> Self { Self::new_with_restriction_product(codex_home, bundled_skills_enabled, Some(Product::Codex)) } @@ -67,7 +83,7 @@ impl SkillsManager { bundled_skills_enabled: bool, restriction_product: Option, ) -> Self { - let manager = Self { + let service = Self { codex_home, restriction_product, extra_roots: RwLock::new(Vec::new()), @@ -77,11 +93,11 @@ impl SkillsManager { if !bundled_skills_enabled { // The loader caches bundled skills under `skills/.system`. Clearing that directory is // best-effort cleanup; root selection still enforces the config even if removal fails. - uninstall_system_skills(&manager.codex_home); - } else if let Err(err) = install_system_skills(&manager.codex_home) { + uninstall_system_skills(&service.codex_home); + } else if let Err(err) = install_system_skills(&service.codex_home) { tracing::error!("failed to install system skills: {err}"); } - manager + service } pub fn set_extra_roots(&self, extra_roots: Vec) { @@ -101,26 +117,34 @@ impl SkillsManager { /// This path uses a cache keyed by the effective skill-relevant config state rather than just /// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen /// to share a directory. - #[instrument(level = "trace", skip_all)] - pub async fn skills_for_config( + #[instrument( + name = "skills_for_config", + level = "info", + skip_all, + fields(otel.name = "skills_for_config") + )] + pub async fn snapshot_for_config( &self, input: &SkillsLoadInput, fs: Option>, - ) -> SkillLoadOutcome { + ) -> HostSkillsSnapshot { let roots = self.skill_roots_for_config(input, fs).await; let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let cache_key = config_skills_cache_key(&roots, &skill_config_rules); - if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { - return outcome; + if let Some(snapshot) = self.cached_snapshot_for_config(&cache_key) { + return snapshot; } - let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; + let snapshot = HostSkillsSnapshot::new(Arc::new( + self.build_skill_outcome(input, roots, &skill_config_rules) + .await, + )); let mut cache = self .cache_by_config .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.insert(cache_key, outcome.clone()); - outcome + cache.insert(cache_key, snapshot.clone()); + snapshot } pub async fn skill_roots_for_config( @@ -142,18 +166,18 @@ impl SkillsManager { roots } - pub async fn skills_for_cwd( + pub async fn snapshot_for_cwd( &self, input: &SkillsLoadInput, force_reload: bool, fs: Option>, - ) -> SkillLoadOutcome { + ) -> HostSkillsSnapshot { let use_cwd_cache = fs.is_some(); if use_cwd_cache && !force_reload - && let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd) + && let Some(snapshot) = self.cached_snapshot_for_cwd(&input.cwd) { - return outcome; + return snapshot; } let mut roots = skill_roots( @@ -168,27 +192,30 @@ impl SkillsManager { roots.retain(|root| root.scope != SkillScope::System); } let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); - let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; + let snapshot = HostSkillsSnapshot::new(Arc::new( + self.build_skill_outcome(input, roots, &skill_config_rules) + .await, + )); if use_cwd_cache { let mut cache = self .cache_by_cwd .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.insert(input.cwd.clone(), outcome.clone()); + cache.insert(input.cwd.clone(), snapshot.clone()); } - outcome + snapshot } #[instrument(level = "trace", skip_all)] async fn build_skill_outcome( &self, + input: &SkillsLoadInput, roots: Vec, skill_config_rules: &SkillConfigRules, ) -> SkillLoadOutcome { - let outcome = crate::filter_skill_load_outcome_for_product( - load_skills_from_roots(roots).await, - self.restriction_product, - ); + let outcome = load_skills_from_roots(roots, input.plugin_skill_snapshots.as_ref()).await; + let outcome = + crate::filter_skill_load_outcome_for_product(outcome, self.restriction_product); let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules); finalize_skill_outcome(outcome, disabled_paths) } @@ -216,17 +243,17 @@ impl SkillsManager { info!("skills cache cleared ({cleared} entries)"); } - fn cached_outcome_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option { + fn cached_snapshot_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option { match self.cache_by_cwd.read() { Ok(cache) => cache.get(cwd).cloned(), Err(err) => err.into_inner().get(cwd).cloned(), } } - fn cached_outcome_for_config( + fn cached_snapshot_for_config( &self, cache_key: &ConfigSkillsCacheKey, - ) -> Option { + ) -> Option { match self.cache_by_config.read() { Ok(cache) => cache.get(cache_key).cloned(), Err(err) => err.into_inner().get(cache_key).cloned(), @@ -243,7 +270,7 @@ impl SkillsManager { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { - roots: Vec<(AbsolutePathBuf, u8, Option)>, + roots: Vec<(AbsolutePathBuf, u8, Option, Option)>, skill_config_rules: SkillConfigRules, } @@ -283,7 +310,12 @@ fn config_skills_cache_key( SkillScope::System => 2, SkillScope::Admin => 3, }; - (root.path.clone(), scope_rank, root.plugin_id.clone()) + ( + root.path.clone(), + scope_rank, + root.plugin_id.clone(), + root.plugin_namespace.clone(), + ) }) .collect(), skill_config_rules: skill_config_rules.clone(), @@ -303,5 +335,5 @@ fn finalize_skill_outcome( } #[cfg(test)] -#[path = "manager_tests.rs"] +#[path = "service_tests.rs"] mod tests; diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/service_tests.rs similarity index 90% rename from codex-rs/core-skills/src/manager_tests.rs rename to codex-rs/core-skills/src/service_tests.rs index 713b94d7b1cf..23a15f79550b 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/service_tests.rs @@ -55,7 +55,11 @@ fn write_plugin_skill( skill_path } -fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> PluginSkillRoot { +fn plugin_skill_root_for_skill_path( + skill_path: &Path, + plugin_id: &str, + plugin_namespace: &str, +) -> PluginSkillRoot { let skills_root = skill_path .parent() .and_then(Path::parent) @@ -66,6 +70,7 @@ fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> Plugi PluginSkillRoot { path: skills_root.abs(), plugin_id: plugin_id.to_string(), + plugin_namespace: plugin_namespace.to_string(), plugin_root: plugin_root.abs(), } } @@ -159,7 +164,7 @@ enabled = {enabled} } async fn skills_for_config_with_stack( - skills_manager: &SkillsManager, + skills_service: &SkillsService, cwd: &TempDir, config_layer_stack: &ConfigLayerStack, effective_skill_roots: &[PluginSkillRoot], @@ -170,9 +175,11 @@ async fn skills_for_config_with_stack( config_layer_stack.clone(), bundled_skills_enabled_from_stack(config_layer_stack), ); - skills_manager - .skills_for_config(&skills_input, Some(Arc::clone(&LOCAL_FS))) + skills_service + .snapshot_for_config(&skills_input, Some(Arc::clone(&LOCAL_FS))) .await + .outcome() + .clone() } #[test] @@ -183,7 +190,7 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n") .expect("write stale system skill"); - let _skills_manager = SkillsManager::new( + let _skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ false, ); @@ -199,14 +206,14 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); write_user_skill(&codex_home, "a", "skill-a", "from a"); let outcome1 = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( outcome1.skills.iter().any(|s| s.name == "skill-a"), "expected skill-a to be discovered" @@ -216,7 +223,7 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { // entry because the effective skill config is unchanged. write_user_skill(&codex_home, "b", "skill-b", "from b"); let outcome2 = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert_eq!(outcome2.errors, outcome1.errors); assert_eq!(outcome2.skills, outcome1.skills); } @@ -227,7 +234,7 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { let cwd = tempfile::tempdir().expect("tempdir"); let extra_root = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); @@ -238,13 +245,14 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let empty_outcome = skills_manager - .skills_for_cwd( + let empty_snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let empty_outcome = empty_snapshot.outcome(); assert!( empty_outcome .skills @@ -260,15 +268,16 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", ) .expect("write skill"); - skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + skills_service.set_extra_roots(vec![extra_skills_root.abs()]); - let runtime_outcome = skills_manager - .skills_for_cwd( + let runtime_snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let runtime_outcome = runtime_snapshot.outcome(); assert!( runtime_outcome .skills @@ -276,14 +285,15 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { .any(|skill| skill.name == "runtime-skill") ); - skills_manager.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]); - let replaced_outcome = skills_manager - .skills_for_cwd( + skills_service.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]); + let replaced_snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let replaced_outcome = replaced_snapshot.outcome(); assert_eq!(replaced_outcome.errors, Vec::new()); assert!( replaced_outcome @@ -299,13 +309,13 @@ async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { let cwd = tempfile::tempdir().expect("tempdir"); let extra_root = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); let empty_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( empty_outcome .skills @@ -321,10 +331,10 @@ async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", ) .expect("write skill"); - skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + skills_service.set_extra_roots(vec![extra_skills_root.abs()]); let runtime_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( runtime_outcome .skills @@ -332,9 +342,9 @@ async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { .any(|skill| skill.name == "runtime-skill") ); - skills_manager.set_extra_roots(Vec::new()); + skills_service.set_extra_roots(Vec::new()); let cleared_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( cleared_outcome .skills @@ -359,14 +369,15 @@ async fn skills_for_config_disables_plugin_skills_by_name() { &codex_home, &name_toggle_config("sample:sample-search", /*enabled*/ false), ); - let plugin_skill_root = plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test"); - let skills_manager = SkillsManager::new( + let plugin_skill_root = + plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test", "sample"); + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); let outcome = skills_for_config_with_stack( - &skills_manager, + &skills_service, &cwd, &config_layer_stack, &[plugin_skill_root], @@ -427,18 +438,19 @@ async fn skills_for_cwd_loads_repo_and_user_roots_with_local_fs() { config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); - let outcome = skills_manager - .skills_for_cwd( + let snapshot = skills_service + .snapshot_for_cwd( &skills_input, /*force_reload*/ true, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome = snapshot.outcome(); assert!( outcome.errors.is_empty(), @@ -490,14 +502,15 @@ async fn skills_for_cwd_without_fs_skips_repo_roots() { config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); - let outcome = skills_manager - .skills_for_cwd(&skills_input, /*force_reload*/ true, /*fs*/ None) + let snapshot = skills_service + .snapshot_for_cwd(&skills_input, /*force_reload*/ true, /*fs*/ None) .await; + let outcome = snapshot.outcome(); assert!( outcome.errors.is_empty(), @@ -525,7 +538,7 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { ) .expect("write bundled skill"); let config_layer_stack = config_stack(&codex_home, "[skills.bundled]\nenabled = false\n"); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ false, ); @@ -540,7 +553,7 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { .expect("rewrite bundled skill"); let outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; assert!( outcome .skills @@ -560,24 +573,25 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); - let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + let _ = skills_for_config_with_stack(&skills_service, &cwd, &config_layer_stack, &[]).await; let base_input = SkillsLoadInput::new( cwd.path().abs(), Vec::new(), config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let outcome_a = skills_manager - .skills_for_cwd( + let snapshot_a = skills_service + .snapshot_for_cwd( &base_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome_a = snapshot_a.outcome(); assert!( outcome_a .skills @@ -587,13 +601,14 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() { write_user_skill(&codex_home, "late", "late-skill", "added after cache"); - let outcome_b = skills_manager - .skills_for_cwd( + let snapshot_b = skills_service + .snapshot_for_cwd( &base_input, /*force_reload*/ false, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome_b = snapshot_b.outcome(); assert!( outcome_b .skills @@ -601,13 +616,14 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() { .all(|skill| skill.name != "late-skill") ); - let outcome_reloaded = skills_manager - .skills_for_cwd( + let snapshot_reloaded = skills_service + .snapshot_for_cwd( &base_input, /*force_reload*/ true, Some(Arc::clone(&LOCAL_FS)), ) .await; + let outcome_reloaded = snapshot_reloaded.outcome(); assert!( outcome_reloaded .skills @@ -775,7 +791,7 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() let parent_stack = config_stack(&codex_home, &disabled_skill_config); let child_stack = config_stack_with_session_flags(&codex_home, &disabled_skill_config, &enabled_skill_config); - let skills_manager = SkillsManager::new( + let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); @@ -786,13 +802,14 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() bundled_skills_enabled_from_stack(&parent_stack), ); - let parent_outcome = skills_manager - .skills_for_cwd( + let parent_snapshot = skills_service + .snapshot_for_cwd( &parent_input, /*force_reload*/ true, Some(Arc::clone(&LOCAL_FS)), ) .await; + let parent_outcome = parent_snapshot.outcome(); let parent_skill = parent_outcome .skills .iter() @@ -801,7 +818,7 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false); let child_outcome = - skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]).await; + skills_for_config_with_stack(&skills_service, &cwd, &child_stack, &[]).await; let child_skill = child_outcome .skills .iter() diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0d0bd1df627a..f7f0bffa67f8 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -268,6 +268,14 @@ ], "description": "Reviewer for approval prompts unless overridden by per-app settings." }, + "default_tools_approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for tools unless overridden by per-app or per-tool settings." + }, "destructive_enabled": { "description": "Whether tools with `destructive_hint = true` are allowed by default.", "type": "boolean" @@ -412,6 +420,13 @@ "CodeModeConfigToml": { "additionalProperties": false, "properties": { + "direct_only_tool_namespaces": { + "description": "Exact tool namespaces to expose only as direct model tools. These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted from the nested code-mode tool surface.", + "items": { + "type": "string" + }, + "type": "array" + }, "enabled": { "type": "boolean" }, @@ -486,13 +501,13 @@ "auth_elicitation": { "type": "boolean" }, - "browser_use": { + "auto_compaction": { "type": "boolean" }, - "browser_use_external": { + "browser_use": { "type": "boolean" }, - "child_agents_md": { + "browser_use_external": { "type": "boolean" }, "chronicle": { @@ -522,9 +537,15 @@ "connectors": { "type": "boolean" }, + "current_time_reminder": { + "$ref": "#/definitions/FeatureToml_for_CurrentTimeReminderConfigToml" + }, "default_mode_request_user_input": { "type": "boolean" }, + "deferred_executor": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, @@ -579,6 +600,9 @@ "in_app_browser": { "type": "boolean" }, + "item_ids": { + "type": "boolean" + }, "js_repl": { "type": "boolean" }, @@ -600,6 +624,9 @@ "multi_agent": { "type": "boolean" }, + "multi_agent_mode": { + "type": "boolean" + }, "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, @@ -651,12 +678,18 @@ "resize_all_images": { "type": "boolean" }, + "respect_system_proxy": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, "responses_websockets_v2": { "type": "boolean" }, + "rollout_budget": { + "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" + }, "runtime_metrics": { "type": "boolean" }, @@ -703,7 +736,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" @@ -732,6 +765,9 @@ "unified_exec_zsh_fork": { "type": "boolean" }, + "use_agent_identity": { + "type": "boolean" + }, "use_legacy_landlock": { "type": "boolean" }, @@ -848,6 +884,30 @@ }, "type": "object" }, + "CurrentTimeReminderConfigToml": { + "additionalProperties": false, + "properties": { + "clock_source": { + "$ref": "#/definitions/CurrentTimeSource" + }, + "enabled": { + "type": "boolean" + }, + "reminder_interval_model_requests": { + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, + "CurrentTimeSource": { + "enum": [ + "system", + "external" + ], + "type": "string" + }, "DebugConfigLockToml": { "additionalProperties": false, "properties": { @@ -940,6 +1000,16 @@ } ] }, + "FeatureToml_for_CurrentTimeReminderConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/CurrentTimeReminderConfigToml" + } + ] + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -960,6 +1030,26 @@ } ] }, + "FeatureToml_for_RolloutBudgetConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/RolloutBudgetConfigToml" + } + ] + }, + "FeatureToml_for_TokenBudgetConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/TokenBudgetConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -2245,6 +2335,7 @@ "type": "string" }, "usage_hint_enabled": { + "description": "Deprecated compatibility field. Its value is ignored.", "type": "boolean" }, "usage_hint_text": { @@ -2623,6 +2714,29 @@ } ] }, + "OrchestratorFeatureToml": { + "additionalProperties": false, + "description": "Settings for a feature owned by the orchestrator.", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, + "OrchestratorToml": { + "additionalProperties": false, + "description": "Orchestrator-owned feature settings.", + "properties": { + "mcp": { + "$ref": "#/definitions/OrchestratorFeatureToml" + }, + "skills": { + "$ref": "#/definitions/OrchestratorFeatureToml" + } + }, + "type": "object" + }, "OtelConfigToml": { "additionalProperties": false, "description": "OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.", @@ -3066,13 +3180,6 @@ }, "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -3083,9 +3190,6 @@ "RealtimeToml": { "additionalProperties": false, "properties": { - "architecture": { - "$ref": "#/definitions/RealtimeConversationArchitecture" - }, "transport": { "$ref": "#/definitions/RealtimeTransport" }, @@ -3172,6 +3276,38 @@ ], "type": "string" }, + "RolloutBudgetConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "limit_tokens": { + "format": "int64", + "minimum": 1.0, + "type": "integer" + }, + "prefill_token_weight": { + "format": "double", + "minimum": 0.0, + "type": "number" + }, + "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", + "minimum": 0.0, + "type": "number" + } + }, + "type": "object" + }, "SandboxMode": { "enum": [ "read-only", @@ -3342,6 +3478,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": { @@ -4948,6 +5105,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" @@ -5225,13 +5383,13 @@ "auth_elicitation": { "type": "boolean" }, - "browser_use": { + "auto_compaction": { "type": "boolean" }, - "browser_use_external": { + "browser_use": { "type": "boolean" }, - "child_agents_md": { + "browser_use_external": { "type": "boolean" }, "chronicle": { @@ -5261,9 +5419,15 @@ "connectors": { "type": "boolean" }, + "current_time_reminder": { + "$ref": "#/definitions/FeatureToml_for_CurrentTimeReminderConfigToml" + }, "default_mode_request_user_input": { "type": "boolean" }, + "deferred_executor": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, @@ -5318,6 +5482,9 @@ "in_app_browser": { "type": "boolean" }, + "item_ids": { + "type": "boolean" + }, "js_repl": { "type": "boolean" }, @@ -5339,6 +5506,9 @@ "multi_agent": { "type": "boolean" }, + "multi_agent_mode": { + "type": "boolean" + }, "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, @@ -5390,12 +5560,18 @@ "resize_all_images": { "type": "boolean" }, + "respect_system_proxy": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, "responses_websockets_v2": { "type": "boolean" }, + "rollout_budget": { + "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" + }, "runtime_metrics": { "type": "boolean" }, @@ -5442,7 +5618,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" @@ -5471,6 +5647,9 @@ "unified_exec_zsh_fork": { "type": "boolean" }, + "use_agent_identity": { + "type": "boolean" + }, "use_legacy_landlock": { "type": "boolean" }, @@ -5743,6 +5922,14 @@ "description": "Base URL override for the built-in `openai` model provider.", "type": "string" }, + "orchestrator": { + "allOf": [ + { + "$ref": "#/definitions/OrchestratorToml" + } + ], + "description": "Orchestrator-owned feature settings." + }, "oss_provider": { "description": "Preferred OSS provider for local models, e.g. \"lmstudio\" or \"ollama\".", "type": "string" @@ -5940,7 +6127,7 @@ "$ref": "#/definitions/WebSearchMode" } ], - "description": "Controls the web search tool mode: disabled, cached, or live." + "description": "Controls the web search tool mode: disabled, cached, indexed, or live." }, "windows": { "allOf": [ diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 5f1246bf5b28..2f8d035435ba 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -6,8 +6,11 @@ use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; use crate::config::Config; +use crate::config::RolloutBudgetConfig; use crate::environment_selection::TurnEnvironmentSnapshot; +use crate::rollout_budget::RolloutBudget; use crate::session::emit_subagent_session_started; +use crate::session_prefix::format_inter_agent_completion_message; use crate::session_prefix::format_subagent_context_line; use crate::session_prefix::format_subagent_notification_message; use crate::thread_manager::ResumeThreadWithHistoryOptions; @@ -16,6 +19,7 @@ use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ContentItem; @@ -65,6 +69,7 @@ pub(crate) struct SpawnAgentOptions { pub(crate) fork_mode: Option, pub(crate) parent_thread_id: Option, pub(crate) environments: Option>, + pub(crate) initial_multi_agent_mode: Option, } #[derive(Clone, Debug)] @@ -99,15 +104,24 @@ pub(crate) struct AgentControl { state: Arc, v2_residency: Arc, agent_execution_limiter: Arc, + /// Session-scoped state shared by the root thread and every cloned sub-agent control handle. + rollout_budget: Arc, } impl AgentControl { /// Construct a new `AgentControl` that can spawn/message agents via the given manager state. - pub(crate) fn new(manager: Weak) -> Self { - Self { + pub(crate) fn new( + manager: Weak, + rollout_budget: Option, + ) -> Self { + let control = Self { manager, ..Default::default() + }; + if let Some(rollout_budget) = rollout_budget { + control.rollout_budget.configure(rollout_budget); } + control } pub(crate) fn with_session_id(mut self, session_id: SessionId, max_threads: usize) -> Self { @@ -120,6 +134,10 @@ impl AgentControl { self.session_id } + pub(crate) fn rollout_budget(&self) -> &RolloutBudget { + self.rollout_budget.as_ref() + } + /// Send rich user input items to an existing agent thread. pub(crate) async fn send_input( &self, @@ -434,7 +452,6 @@ impl AgentControl { return; }; let child_thread = state.get_thread(child_thread_id).await.ok(); - let message = format_subagent_notification_message(child_reference.as_str(), &status); let child_uses_multi_agent_v2 = match child_thread.as_ref() { Some(child_thread) => { child_thread.multi_agent_version() == Some(MultiAgentVersion::V2) @@ -452,6 +469,13 @@ impl AgentControl { else { return; }; + let Some(message) = format_inter_agent_completion_message( + parent_agent_path.clone(), + child_agent_path.clone(), + &status, + ) else { + return; + }; let communication = InterAgentCommunication::new( child_agent_path, parent_agent_path, @@ -464,6 +488,7 @@ impl AgentControl { .await; return; } + let message = format_subagent_notification_message(child_reference.as_str(), &status); let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { return; }; diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index cf043971e269..f91a77364ae0 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -142,6 +142,7 @@ async fn spawn_v2_subagent( /*forked_from_thread_id*/ None, Some(ThreadSource::Subagent), /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*environments*/ None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index bc6dba1a48cf..05fac6aaf3b3 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1,11 +1,13 @@ use super::residency::is_v2_resident_session_source; use super::*; +use codex_protocol::config_types::MultiAgentMode; const AGENT_NAMES: &str = include_str!("../agent_names.txt"); struct SpawnAgentThreadInheritance { environments: Option, exec_policy: Option>, + inherited_multi_agent_mode: Option, } fn default_agent_nickname_list() -> Vec<&'static str> { @@ -237,6 +239,7 @@ impl AgentControl { exec_policy: self .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) .await, + inherited_multi_agent_mode: options.initial_multi_agent_mode, }; let (session_source, mut agent_metadata) = match session_source { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -283,6 +286,7 @@ impl AgentControl { /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*metrics_service_name*/ None, + inheritance.inherited_multi_agent_mode, inheritance.environments, inheritance.exec_policy, options.environments.clone(), @@ -388,6 +392,7 @@ impl AgentControl { let SpawnAgentThreadInheritance { environments: inherited_environments, exec_policy: inherited_exec_policy, + inherited_multi_agent_mode, } = inheritance; if options.fork_parent_spawn_call_id.is_none() { return Err(CodexErr::Fatal( @@ -493,7 +498,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) = @@ -513,6 +517,7 @@ impl AgentControl { /*thread_source*/ Some(ThreadSource::Subagent), /*parent_thread_id*/ Some(parent_thread_id), /*forked_from_thread_id*/ Some(parent_thread_id), + inherited_multi_agent_mode, inherited_environments, inherited_exec_policy, options.environments.clone(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 410e1beaf685..17532bd78523 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -14,6 +14,7 @@ use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::AgentPath; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; @@ -71,7 +72,7 @@ fn assistant_message(text: &str, phase: Option) -> ResponseItem { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -91,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, } } @@ -816,6 +817,42 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn spawn_thread_subagent_uses_supplied_initial_multi_agent_mode_without_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + initial_multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }, + ) + .await + .expect("spawn child without parent history") + .thread_id; + let child_snapshot = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered") + .config_snapshot() + .await; + + assert_eq!(child_snapshot.multi_agent_mode, MultiAgentMode::Proactive); +} + #[tokio::test] async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let harness = AgentControlHarness::new().await; @@ -838,6 +875,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("start parent thread"); let parent_thread_id = new_thread.thread_id; let parent_thread = new_thread.thread; + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode"); parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; @@ -863,7 +909,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, @@ -872,17 +918,17 @@ 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)), assistant_message("parent unknown phase", /*phase*/ None), ResponseItem::Reasoning { - id: "parent-reasoning".to_string(), + id: Some("parent-reasoning".to_string()), 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), @@ -923,6 +969,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), fork_mode: Some(SpawnAgentForkMode::FullHistory), + initial_multi_agent_mode: Some(MultiAgentMode::Proactive), ..Default::default() }, ) @@ -935,6 +982,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .get_thread(child_thread_id) .await .expect("child thread should be registered"); + assert_eq!( + child_thread.config_snapshot().await.multi_agent_mode, + MultiAgentMode::Proactive + ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; let expected_history = [ @@ -945,7 +996,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "parent seed context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), ResponseItem::Message { @@ -955,7 +1006,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!( @@ -971,18 +1022,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, @@ -998,24 +1044,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 = ( @@ -1045,9 +1084,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 @@ -1086,7 +1125,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, @@ -1095,7 +1134,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 @@ -1105,6 +1144,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { RolloutItem::Compacted(CompactedItem { 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()), @@ -1241,6 +1283,15 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { let harness = AgentControlHarness::new().await; let (parent_thread_id, parent_thread) = harness.start_thread().await; + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode"); parent_thread .inject_user_message_without_turn("old parent context".to_string()) @@ -1325,6 +1376,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + initial_multi_agent_mode: Some(MultiAgentMode::Proactive), ..Default::default() }, ) @@ -1337,6 +1389,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .get_thread(child_thread_id) .await .expect("child thread should be registered"); + assert_eq!( + child_thread.config_snapshot().await.multi_agent_mode, + MultiAgentMode::Proactive + ); let history = child_thread.codex.session.clone_history().await; assert!( @@ -1393,7 +1449,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; @@ -1515,7 +1571,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), ], @@ -2000,10 +2056,12 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { ) .await; - let expected_message = crate::session_prefix::format_subagent_notification_message( - tester_path.as_str(), + let expected_message = crate::session_prefix::format_inter_agent_completion_message( + worker_path.clone(), + tester_path.clone(), &AgentStatus::Completed(Some("done".to_string())), - ); + ) + .expect("completed status should render"); let expected = ( worker_thread_id, Op::InterAgentCommunication { @@ -2182,7 +2240,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { } #[tokio::test] -async fn resume_thread_subagent_restores_stored_nickname_and_role() { +async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_agent_mode() { let (home, mut config) = test_config().await; config .features @@ -2204,7 +2262,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { manager, control, }; - let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; let agent_path = AgentPath::from_string("/root/explorer".to_string()) .expect("test agent path should be valid"); @@ -2229,6 +2287,38 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { .get_thread(child_thread_id) .await .expect("child thread should exist"); + let mut child_turn_context = child_thread + .codex + .session + .new_default_turn() + .await + .to_turn_context_item(); + child_turn_context.multi_agent_mode = Some(MultiAgentMode::Proactive); + child_thread + .codex + .session + .persist_rollout_items(&[RolloutItem::TurnContext(child_turn_context)]) + .await; + child_thread + .codex + .session + .ensure_rollout_materialized() + .await; + child_thread + .codex + .session + .flush_rollout() + .await + .expect("flush child effective multi-agent mode"); + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::ExplicitRequestOnly), + ..Default::default() + }) + .await + .expect("change parent multi-agent mode before child resume"); let mut status_rx = harness .control .subscribe_status(child_thread_id) @@ -2317,6 +2407,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { 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, MultiAgentMode::Proactive); let _ = harness .control diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 73353bbe75d3..912c318d5c79 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::SkillsManager; +use crate::SkillsService; use crate::config::ConfigBuilder; use crate::skills_load_input_from_config; use codex_config::ConfigLayerStackOrdering; @@ -486,18 +486,21 @@ enabled = false .expect("custom role should apply"); let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf())); - let skills_manager = - SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true); + let skills_service = + SkillsService::new(home.path().abs(), /*bundled_skills_enabled*/ true); let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(&config, effective_skill_roots); - let outcome = skills_manager - .skills_for_config( + let plugin_skill_snapshots = plugins_manager.plugin_skill_snapshots_for_config(&plugins_input); + let skills_input = skills_load_input_from_config(&config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); + let snapshot = skills_service + .snapshot_for_config( &skills_input, Some(Arc::clone(&codex_exec_server::LOCAL_FS)), ) .await; + let outcome = snapshot.outcome(); let skill = outcome .skills .iter() diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 5cb70dc5fc4e..d0dc7026a937 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -26,8 +26,6 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::ExecutorFileSystem; use codex_extension_api::UserInstructions; -use codex_features::Feature; -use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::io; @@ -72,13 +70,6 @@ pub(crate) async fn load_project_instructions( } } - if config.features.enabled(Feature::ChildAgentsMd) { - loaded.entries.push(InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }); - } - (!loaded.is_empty()).then_some(loaded) } @@ -92,7 +83,7 @@ async fn read_agents_md( config: &Config, fs: &dyn ExecutorFileSystem, environment_id: &str, - cwd: &AbsolutePathBuf, + cwd: &PathUri, ) -> io::Result> { let max_total = config.project_doc_max_bytes; @@ -113,15 +104,14 @@ async fn read_agents_md( break; } - let path_uri = PathUri::from_abs_path(&p); - match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + match fs.get_metadata(&p, /*sandbox*/ None).await { Ok(metadata) if !metadata.is_file => continue, Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), } - let mut data = match fs.read_file(&path_uri, /*sandbox*/ None).await { + let mut data = match fs.read_file(&p, /*sandbox*/ None).await { Ok(data) => data, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), @@ -133,9 +123,9 @@ async fn read_agents_md( if size > remaining { tracing::warn!( - "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", - p.display(), - remaining, + path = %p, + remaining_bytes = remaining, + "project doc exceeds remaining budget; truncating" ); } @@ -164,9 +154,9 @@ async fn read_agents_md( /// directory, inclusive. Symlinks are allowed. async fn agents_md_paths( config: &Config, - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, -) -> io::Result> { +) -> io::Result> { let dir = cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); @@ -189,18 +179,18 @@ async fn agents_md_paths( }; let mut project_root = None; if !project_root_markers.is_empty() { - for ancestor in dir.ancestors() { + for current in dir.ancestors() { for marker in &project_root_markers { - let marker_path = ancestor.join(marker); - let marker_path_uri = PathUri::from_abs_path(&marker_path); - let marker_exists = match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await - { + let marker_path = current + .join(marker) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await { Ok(_) => true, Err(err) if err.kind() == io::ErrorKind::NotFound => false, Err(err) => return Err(err), }; if marker_exists { - project_root = Some(ancestor.clone()); + project_root = Some(current.clone()); break; } } @@ -210,7 +200,7 @@ async fn agents_md_paths( } } - let search_dirs: Vec = if let Some(root) = project_root { + let search_dirs: Vec = if let Some(root) = project_root { let mut dirs = Vec::new(); let mut cursor = dir.clone(); loop { @@ -229,13 +219,14 @@ async fn agents_md_paths( vec![dir] }; - let mut found: Vec = Vec::new(); + let mut found: Vec = Vec::new(); let candidate_filenames = candidate_filenames(config); for d in search_dirs { for name in &candidate_filenames { - let candidate = d.join(name); - let candidate_uri = PathUri::from_abs_path(&candidate); - match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await { + let candidate = d + .join(name) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + match fs.get_metadata(&candidate, /*sandbox*/ None).await { Ok(md) if md.is_file => { found.push(candidate); break; @@ -366,7 +357,7 @@ impl LoadedAgentsMd { fn environment_labeled_text(&self) -> String { let mut output = String::new(); let mut has_previous = false; - let mut previous_environment: Option<(&str, &AbsolutePathBuf)> = None; + let mut previous_environment: Option<(&str, &PathUri)> = None; if let Some(instructions) = &self.user_instructions { output.push_str(&instructions.text); has_previous = true; @@ -389,7 +380,7 @@ impl LoadedAgentsMd { output.push_str(&format!( "for `{}` with root {}\n\n", environment_id, - cwd.display() + cwd.inferred_native_path_string() )); } output.push_str(&entry.contents); @@ -416,7 +407,7 @@ impl LoadedAgentsMd { None } else { self.single_project_cwd() - .map(|cwd| cwd.to_string_lossy().into_owned()) + .map(PathUri::inferred_native_path_string) }; ContextUserInstructions { directory, @@ -431,10 +422,10 @@ impl LoadedAgentsMd { } /// Returns the AGENTS.md files that supplied instruction entries. - pub fn sources(&self) -> impl Iterator { + pub fn sources(&self) -> impl Iterator + '_ { self.user_instructions .iter() - .map(|instructions| &instructions.source) + .map(|instructions| PathUri::from_abs_path(&instructions.source)) .chain( self.entries .iter() @@ -458,7 +449,7 @@ impl LoadedAgentsMd { }) } - fn single_project_cwd(&self) -> Option<&AbsolutePathBuf> { + fn single_project_cwd(&self) -> Option<&PathUri> { self.entries .iter() .find_map(|entry| match &entry.provenance { @@ -483,9 +474,9 @@ enum InstructionProvenance { /// Workspace instructions discovered from project AGENTS.md files. Project { /// Exact AGENTS.md file, distinct from the environment's selected cwd. - source_path: AbsolutePathBuf, + source_path: PathUri, environment_id: String, - cwd: AbsolutePathBuf, + cwd: PathUri, }, /// Instructions without a file source, including internally defined guidance. @@ -493,9 +484,9 @@ enum InstructionProvenance { } impl InstructionProvenance { - fn path(&self) -> Option<&AbsolutePathBuf> { + fn path(&self) -> Option { match self { - Self::Project { source_path, .. } => Some(source_path), + Self::Project { source_path, .. } => Some(source_path.clone()), Self::Internal => None, } } diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 92cb0d5bebfa..0122910c3b94 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -11,6 +11,7 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystemFuture; use codex_exec_server::FileMetadata; +use codex_exec_server::FileSystemReadStream; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::LOCAL_FS; use codex_exec_server::ReadDirectoryEntry; @@ -140,6 +141,19 @@ impl ExecutorFileSystem for FailingFileSystem { Box::pin(FailingFileSystem::read_file(self, path, sandbox)) } + fn read_file_stream<'a>( + &'a self, + _path: &'a PathUri, + _sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(async { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "failing filesystem does not support streaming reads", + )) + }) + } + fn write_file<'a>( &'a self, path: &'a PathUri, @@ -236,8 +250,13 @@ async fn load_agents_md(config: &TestConfig) -> Option { .await } -async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { - super::agents_md_paths(&config.config, &config.cwd, LOCAL_FS.as_ref()).await +async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { + super::agents_md_paths( + &config.config, + &PathUri::from_abs_path(&config.cwd), + LOCAL_FS.as_ref(), + ) + .await } fn resolved_local_environments( @@ -253,22 +272,112 @@ fn resolved_local_environments( Environment::create_for_tests(/*exec_server_url*/ None) .expect("local environment"), ), - cwd, + PathUri::from_abs_path(&cwd), /*shell*/ None, ) }) .collect(), + starting: Vec::new(), } } fn project_provenance(path: AbsolutePathBuf, cwd: AbsolutePathBuf) -> InstructionProvenance { InstructionProvenance::Project { - source_path: path, + source_path: PathUri::from_abs_path(&path), environment_id: "local".to_string(), - cwd, + cwd: PathUri::from_abs_path(&cwd), } } +#[test] +fn foreign_agents_md_uses_environment_native_paths() { + let (cwd, rendered_cwd) = if cfg!(windows) { + ( + PathUri::parse("file:///codex%20runtime").expect("POSIX cwd URI"), + "/codex runtime", + ) + } else { + ( + PathUri::parse("file:///C:/codex%20runtime").expect("Windows cwd URI"), + r"C:\codex runtime", + ) + }; + let source_path = cwd.join("AGENTS.md").expect("AGENTS.md URI"); + let loaded = LoadedAgentsMd { + user_instructions: None, + entries: vec![InstructionEntry { + contents: "remote instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: source_path.clone(), + environment_id: "remote".to_string(), + cwd, + }, + }], + }; + + assert_eq!( + loaded.render(), + format!( + "# AGENTS.md instructions for {rendered_cwd} + + +remote instructions +" + ) + ); + assert_eq!(loaded.sources().collect::>(), vec![source_path]); +} + +#[test] +fn multi_environment_agents_md_renders_mixed_path_conventions() { + let posix_cwd = PathUri::parse("file:///srv/project").expect("POSIX cwd URI"); + let windows_cwd = PathUri::parse("file:///C:/workspace").expect("Windows cwd URI"); + let posix_source = posix_cwd.join("AGENTS.md").expect("POSIX AGENTS.md URI"); + let windows_source = windows_cwd + .join("AGENTS.md") + .expect("Windows AGENTS.md URI"); + let loaded = LoadedAgentsMd { + user_instructions: None, + entries: vec![ + InstructionEntry { + contents: "POSIX instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: posix_source.clone(), + environment_id: "posix".to_string(), + cwd: posix_cwd, + }, + }, + InstructionEntry { + contents: "Windows instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: windows_source.clone(), + environment_id: "windows".to_string(), + cwd: windows_cwd, + }, + }, + ], + }; + + assert_eq!( + loaded.render(), + r#"# AGENTS.md instructions + + +for `posix` with root /srv/project + +POSIX instructions + +for `windows` with root C:\workspace + +Windows instructions +"# + ); + assert_eq!( + loaded.sources().collect::>(), + vec![posix_source, windows_source] + ); +} + /// Helper that returns a `Config` pointing at `root` and using `limit` as /// the maximum number of bytes to embed from AGENTS.md. The caller can /// optionally specify a custom `instructions` string – when `None` the @@ -494,7 +603,7 @@ async fn read_agents_md_propagates_metadata_errors() { }; let cwd = config.cwd.clone(); - let err = read_agents_md(&config.config, &fs, "local", &cwd) + let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect_err("metadata error"); @@ -512,7 +621,7 @@ async fn read_agents_md_propagates_read_errors() { }; let cwd = config.cwd.clone(); - let err = read_agents_md(&config.config, &fs, "local", &cwd) + let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect_err("read error"); @@ -530,7 +639,7 @@ async fn read_agents_md_ignores_files_removed_after_discovery() { }; let cwd = config.cwd.clone(); - let loaded = read_agents_md(&config.config, &fs, "local", &cwd) + let loaded = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect("removed file is recoverable"); @@ -645,17 +754,18 @@ secondary doc"#, ); assert_eq!(loaded.render(), expected_fragment); assert_eq!( - loaded.sources().cloned().collect::>(), + loaded.sources().collect::>(), vec![ - config - .user_instructions - .as_ref() - .expect("global instructions") - .source - .clone(), - primary.path().join("AGENTS.md").abs(), - primary_nested.join("AGENTS.md").abs(), - secondary.path().join("AGENTS.md").abs(), + PathUri::from_abs_path( + &config + .user_instructions + .as_ref() + .expect("global instructions") + .source, + ), + PathUri::from_abs_path(&primary.path().join("AGENTS.md").abs()), + PathUri::from_abs_path(&primary_nested.join("AGENTS.md").abs()), + PathUri::from_abs_path(&secondary.path().join("AGENTS.md").abs()), ] ); } @@ -800,32 +910,6 @@ async fn secondary_environment_invalid_utf8_does_not_suppress_other_docs() { assert!(loaded.text().contains("secondary\u{FFFD}doc")); } -#[tokio::test] -async fn child_agents_guidance_is_appended_once_after_environment_groups() { - let primary = tempfile::tempdir().expect("primary tempdir"); - let secondary = tempfile::tempdir().expect("secondary tempdir"); - fs::write(primary.path().join("AGENTS.md"), "primary doc").unwrap(); - fs::write(secondary.path().join("AGENTS.md"), "secondary doc").unwrap(); - let mut config = make_config(&primary, /*limit*/ 4096, /*instructions*/ None).await; - config.features.enable(Feature::ChildAgentsMd).unwrap(); - let environments = resolved_local_environments([ - ("primary", config.cwd.clone()), - ("secondary", secondary.abs()), - ]); - - let loaded = load_project_instructions( - &config.config, - /*user_instructions*/ None, - &environments, - ) - .await - .expect("instructions expected"); - let text = loaded.text(); - - assert_eq!(text.matches(HIERARCHICAL_AGENTS_MESSAGE).count(), 1); - assert!(text.ends_with(HIERARCHICAL_AGENTS_MESSAGE)); -} - /// If there are existing system instructions but AGENTS.md docs are /// missing we expect the original instructions to be returned unchanged. #[tokio::test] @@ -884,7 +968,10 @@ async fn concatenates_root_and_cwd_docs() { assert_eq!(loaded.text(), "root doc\n\ncrate doc"); assert_eq!( loaded.sources().collect::>(), - vec![&root_agents, &crate_agents] + vec![ + PathUri::from_abs_path(&root_agents), + PathUri::from_abs_path(&crate_agents), + ] ); } @@ -911,8 +998,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { let expected_parent = root.path().join("AGENTS.md").abs(); let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); - assert_eq!(discovery[0], expected_parent); - assert_eq!(discovery[1], expected_child); + assert_eq!(discovery[0], PathUri::from_abs_path(&expected_parent)); + assert_eq!(discovery[1], PathUri::from_abs_path(&expected_child)); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "parent doc\n\nchild doc"); @@ -957,8 +1044,8 @@ async fn project_layers_do_not_override_project_root_markers() { assert_eq!( discovery, vec![ - root.path().join("AGENTS.md").abs(), - config.cwd.join("AGENTS.md"), + PathUri::from_abs_path(&root.path().join("AGENTS.md").abs()), + PathUri::from_abs_path(&config.cwd.join("AGENTS.md")), ] ); } @@ -977,38 +1064,15 @@ async fn agents_md_paths_preserve_symlinked_cwd() { cfg.cwd = linked_cwd.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + assert_eq!( + discovery, + vec![PathUri::from_abs_path(&cfg.cwd.join("AGENTS.md"))] + ); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "project doc"); } -#[tokio::test] -async fn child_agents_message_after_global_instructions_uses_plain_separator() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; - cfg.features.enable(Feature::ChildAgentsMd).unwrap(); - - let loaded = load_agents_md(&cfg).await.expect("instructions expected"); - let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); - let expected = LoadedAgentsMd { - user_instructions: Some(UserInstructions { - text: "global doc".to_string(), - source: global_agents, - }), - entries: vec![InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }], - }; - - assert_eq!(loaded, expected); - assert_eq!( - loaded.text(), - format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") - ); -} - #[tokio::test] async fn instruction_sources_include_global_before_agents_md_docs() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1036,7 +1100,10 @@ async fn instruction_sources_include_global_before_agents_md_docs() { assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref()); assert_eq!( loaded.sources().collect::>(), - vec![&global_agents, &project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); assert_eq!( loaded.text(), @@ -1044,47 +1111,6 @@ async fn instruction_sources_include_global_before_agents_md_docs() { ); } -#[tokio::test] -async fn child_agents_message_after_project_docs_is_not_an_instruction_source() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); - - let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; - cfg.features.enable(Feature::ChildAgentsMd).unwrap(); - let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); - fs::create_dir_all(&cfg.codex_home).unwrap(); - fs::write(&global_agents, "global doc").unwrap(); - - let loaded = load_agents_md(&cfg).await.expect("instructions expected"); - let project_agents = cfg.cwd.join("AGENTS.md"); - - let expected = LoadedAgentsMd { - user_instructions: Some(UserInstructions { - text: "global doc".to_string(), - source: global_agents.clone(), - }), - entries: vec![ - InstructionEntry { - contents: "project doc".to_string(), - provenance: project_provenance(project_agents.clone(), cfg.cwd.clone()), - }, - InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }, - ], - }; - assert_eq!(loaded, expected); - assert_eq!( - loaded.sources().collect::>(), - vec![&global_agents, &project_agents] - ); - assert_eq!( - loaded.text(), - format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") - ); -} - /// AGENTS.override.md is preferred over AGENTS.md when both are present. #[tokio::test] async fn agents_local_md_preferred() { @@ -1103,8 +1129,8 @@ async fn agents_local_md_preferred() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); assert_eq!( - discovery[0].file_name().unwrap().to_string_lossy(), - LOCAL_AGENTS_MD_FILENAME + discovery[0].basename().as_deref(), + Some(LOCAL_AGENTS_MD_FILENAME) ); } @@ -1152,12 +1178,9 @@ async fn agents_md_preferred_over_fallbacks() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); - assert!( - discovery[0] - .file_name() - .unwrap() - .to_string_lossy() - .eq(DEFAULT_AGENTS_MD_FILENAME) + assert_eq!( + discovery[0].basename().as_deref(), + Some(DEFAULT_AGENTS_MD_FILENAME) ); } @@ -1172,7 +1195,7 @@ async fn agents_md_directory_is_ignored() { assert_eq!(res, None); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, Vec::::new()); + assert_eq!(discovery, Vec::::new()); } #[cfg(unix)] @@ -1195,7 +1218,7 @@ async fn agents_md_special_file_is_ignored() { assert_eq!(res, None); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, Vec::::new()); + assert_eq!(discovery, Vec::::new()); } #[tokio::test] @@ -1214,11 +1237,8 @@ async fn override_directory_falls_back_to_agents_md_file() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); assert_eq!( - discovery[0] - .file_name() - .expect("file name") - .to_string_lossy(), - DEFAULT_AGENTS_MD_FILENAME + discovery[0].basename().as_deref(), + Some(DEFAULT_AGENTS_MD_FILENAME) ); } diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 38dc45ec1f85..5e9a3f6e5a25 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -7,6 +7,7 @@ use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::FileSystemSandboxPolicy; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; @@ -91,9 +92,11 @@ pub(crate) fn convert_apply_patch_to_protocol( new_content: _new_content, } => FileChange::Update { unified_diff: unified_diff.clone(), - move_path: move_path.clone(), + move_path: move_path.as_ref().map(PathUri::to_path_buf), }, }; + // TODO(anp): Carry PathUri through patch protocol events once app-server and rollout + // compatibility no longer require path-flavored strings. result.insert(path.to_path_buf(), protocol_change); } result diff --git a/codex-rs/core/src/apply_patch_tests.rs b/codex-rs/core/src/apply_patch_tests.rs index c0190c3708b0..dfc48f8a63d7 100644 --- a/codex-rs/core/src/apply_patch_tests.rs +++ b/codex-rs/core/src/apply_patch_tests.rs @@ -1,5 +1,5 @@ use super::*; -use core_test_support::PathBufExt; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -7,14 +7,14 @@ use tempfile::tempdir; #[test] fn convert_apply_patch_maps_add_variant() { let tmp = tempdir().expect("tmp"); - let p = tmp.path().join("a.txt").abs(); - // Create an action with a single Add change - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); + let path = tmp.path().join("a.txt"); + let path_uri = PathUri::from_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); assert_eq!( - got.get(p.as_path()), + got.get(path.as_path()), Some(&FileChange::Add { content: "hello".to_string() }) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f6e690009938..badb4d051229 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -82,7 +82,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::InternalSessionSource; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::W3cTraceContext; @@ -184,6 +183,7 @@ struct ModelClientState { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + item_ids_enabled: bool, include_attestation: bool, attestation_provider: Option>, disable_websockets: AtomicBool, @@ -405,6 +405,7 @@ impl ModelClient { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + item_ids_enabled: bool, attestation_provider: Option>, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); @@ -425,6 +426,7 @@ impl ModelClient { enable_request_compression, include_timing_metrics, beta_features_header, + item_ids_enabled, include_attestation, attestation_provider, disable_websockets: AtomicBool::new(false), @@ -460,6 +462,7 @@ impl ModelClient { self.state.enable_request_compression, self.state.include_timing_metrics, self.state.beta_features_header.clone(), + self.state.item_ids_enabled, self.state.attestation_provider.clone(), ) .with_prompt_cache_key_override(self.prompt_cache_key_override.clone()); @@ -582,7 +585,7 @@ impl ModelClient { let ResponsesApiRequest { model, instructions, - input, + mut input, tools, parallel_tool_calls, reasoning, @@ -591,6 +594,7 @@ impl ModelClient { text, .. } = request; + self.prepare_response_items_for_request(&mut input, /*store*/ false); let payload = ApiCompactionInput { model: &model, input: &input, @@ -645,7 +649,6 @@ impl ModelClient { &self, sdp: String, session_config: ApiRealtimeSessionConfig, - architecture: RealtimeConversationArchitecture, mut extra_headers: ApiHeaderMap, api_provider_override: Option, ) -> Result { @@ -668,12 +671,7 @@ impl ModelClient { let transport = ReqwestTransport::new(build_reqwest_client()); let api_provider = api_provider_override.unwrap_or(client_setup.api_provider); let response = ApiRealtimeCallClient::new(transport, api_provider, client_setup.api_auth) - .create_with_session_architecture_and_headers( - sdp, - session_config, - architecture, - extra_headers, - ) + .create_with_session_and_headers(sdp, session_config, extra_headers) .await .map_err(map_api_error)?; Ok(RealtimeWebrtcCallStart { @@ -858,7 +856,9 @@ impl ModelClient { 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 reasoning = Self::build_reasoning(model_info, effort, summary); @@ -904,6 +904,16 @@ impl ModelClient { Ok(request) } + fn prepare_response_items_for_request(&self, input: &mut [ResponseItem], store: bool) { + if self.state.item_ids_enabled || store { + return; + } + + for item in input { + item.set_id(/*new_id*/ None); + } + } + /// Returns whether the Responses-over-WebSocket transport is active for this session. /// /// WebSocket use is controlled by provider capability and session-scoped fallback state. @@ -1190,7 +1200,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 { @@ -1456,7 +1466,7 @@ impl ModelClientSession { ) .await; - let request = self.client.build_responses_request( + let mut request = self.client.build_responses_request( &client_setup.api_provider, prompt, model_info, @@ -1465,6 +1475,9 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; + let store = request.store; + self.client + .prepare_response_items_for_request(&mut request.input, store); let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.add_request_headers(&mut options.extra_headers); inference_trace_attempt.record_started(&request); @@ -1650,6 +1663,10 @@ impl ModelClientSession { inference_trace.start_attempt() }; stamp_ws_stream_request_start_ms(&mut ws_request); + let ResponsesWsRequest::ResponseCreate(ws_payload) = &mut ws_request; + let store = ws_payload.store; + self.client + .prepare_response_items_for_request(&mut ws_payload.input, store); if previous_response_id_from_untraced_warmup { // The transport can reuse an untraced warmup response id and omit the // already-sent input, but rollout replay needs the logical model-visible diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index b75b03ce2fe2..caa672c10e22 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,5 +1,4 @@ pub use codex_api::ResponseEvent; -use codex_config::types::Personality; use codex_protocol::config_types::Verbosity; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; @@ -30,9 +29,6 @@ pub struct Prompt { pub base_instructions: BaseInstructions, - /// Optionally specify the personality of the model. - pub personality: Option, - /// Optional the output schema for the model's response. pub output_schema: Option, @@ -51,7 +47,6 @@ impl Default for Prompt { tools: Vec::new(), parallel_tool_calls: false, base_instructions: BaseInstructions::default(), - personality: None, output_schema: None, output_schema_strict: true, verbosity: None, diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index b71acc398984..8b25c7e30078 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -20,9 +20,10 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Original), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -30,9 +31,10 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::High), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-call".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -41,7 +43,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Auto), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ..Default::default() @@ -66,9 +68,10 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -76,9 +79,10 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-call".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -87,7 +91,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 3ee4a29b3102..7145895978fc 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -43,7 +43,6 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::InternalSessionSource; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -105,6 +104,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ) } @@ -286,7 +286,7 @@ fn output_message(id: &str, text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -298,7 +298,7 @@ fn input_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -406,6 +406,7 @@ async fn account_pool_model_client( /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ) } @@ -652,12 +653,11 @@ async fn realtime_webrtc_sideband_uses_same_account_pool_auth() -> anyhow::Resul instructions: "test instructions".to_string(), model: Some("gpt-realtime".to_string()), session_id: Some("session-1".to_string()), - event_parser: RealtimeEventParser::RealtimeV2, + event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, voice: RealtimeVoice::Marin, }, - RealtimeConversationArchitecture::RealtimeApi, http::HeaderMap::new(), /*api_provider_override*/ None, ) @@ -958,6 +958,7 @@ fn model_client_with_counting_attestation( /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, Some(Arc::new(CountingAttestationProvider { calls: attestation_calls.clone(), })), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d31dc138b57f..9f5b867f7512 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,7 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( .services .turn_environments .environment_manager(), - skills_manager: Arc::clone(&parent_session.services.skills_manager), + skills_service: Arc::clone(&parent_session.services.skills_service), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), extensions: Arc::clone(&parent_session.services.extensions), @@ -113,10 +113,16 @@ pub(crate) async fn run_codex_thread_interactive( parent_trace: None, environment_selections: parent_ctx.environments.to_selections(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: parent_session + .services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), attestation_provider: parent_session.services.attestation_provider.clone(), + external_time_provider: Some(Arc::clone(&parent_session.services.time_provider)), inherited_multi_agent_version: Some(MultiAgentVersion::Disabled), + initial_multi_agent_mode: None, })) .or_cancel(&cancel_token) .await??; @@ -460,6 +466,7 @@ async fn handle_exec_approval( let ExecApprovalRequestEvent { call_id, approval_id, + environment_id, command, cwd, reason, @@ -505,6 +512,7 @@ async fn handle_exec_approval( parent_ctx, call_id, approval_id, + environment_id, command, cwd, reason, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index f97e81c6e8f5..6d51a0032b24 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, }, }), }) @@ -322,6 +322,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f call_id: "command-item-1".to_string(), approval_id: Some("callback-approval-1".to_string()), turn_id: "child-turn-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()], cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 451b497827bd..a522cd62aa63 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -8,6 +8,7 @@ use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::WindowsSandboxLevel; @@ -24,6 +25,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -41,6 +43,8 @@ use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use rmcp::model::ReadResourceRequestParams; use std::collections::BTreeMap; use std::collections::HashMap; @@ -68,6 +72,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_summary: Option, pub personality: Option, pub collaboration_mode: CollaborationMode, + pub multi_agent_mode: MultiAgentMode, pub session_source: SessionSource, pub forked_from_thread_id: Option, pub parent_thread_id: Option, @@ -148,6 +153,7 @@ pub struct CodexThreadSettingsOverrides { pub summary: Option, pub service_tier: Option>, pub collaboration_mode: Option, + pub multi_agent_mode: Option, pub personality: Option, } @@ -164,7 +170,7 @@ pub struct BackgroundTerminalInfo { pub item_id: String, pub process_id: String, pub command: String, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, } /// Conduit for the bidirectional stream of messages that compose a thread @@ -333,6 +339,13 @@ impl CodexThread { .await } + pub async fn set_openai_form_elicitation_support(&self, supported: bool) -> anyhow::Result<()> { + self.codex + .session + .set_openai_form_elicitation_support(supported) + .await + } + /// Preview persistent thread settings overrides without committing them. pub async fn preview_thread_settings_overrides( &self, @@ -361,6 +374,7 @@ impl CodexThread { summary, service_tier, collaboration_mode, + multi_agent_mode, personality, } = overrides; let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { @@ -384,6 +398,7 @@ impl CodexThread { active_permission_profile, windows_sandbox_level, collaboration_mode: Some(collaboration_mode), + multi_agent_mode, reasoning_summary: summary, service_tier, personality, @@ -437,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 @@ -534,6 +549,18 @@ impl CodexThread { live_thread.update_metadata(patch, include_archived).await } + /// Appends rollout items through the live thread so derived metadata stays in sync. + pub async fn append_rollout_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> { + let live_thread = self + .codex + .session + .live_thread_for_persistence("append rollout items") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread.append_items(items).await + } + pub fn state_db(&self) -> Option { self.codex.state_db() } @@ -543,10 +570,19 @@ impl CodexThread { } /// Returns the files that supplied the thread's loaded model instructions. - pub async fn instruction_sources(&self) -> Vec { + pub async fn instruction_sources(&self) -> Vec { self.codex.instruction_sources().await } + /// Returns loaded instruction sources rendered as legacy app-server path strings. + pub async fn legacy_instruction_sources(&self) -> Vec { + self.instruction_sources() + .await + .into_iter() + .map(Into::into) + .collect() + } + pub async fn config(&self) -> Arc { self.codex.session.get_config().await } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 52b2ef4ef501..94c26b8ad7f8 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; @@ -77,7 +77,12 @@ pub(crate) async fn run_inline_auto_compact_task( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { - let prompt = turn_context.compact_prompt().to_string(); + let prompt = turn_context + .config + .compact_prompt + .as_deref() + .unwrap_or(SUMMARIZATION_PROMPT) + .to_string(); let input = vec![UserInput::Text { text: prompt, // Compaction prompt is synthesized; no UI element ranges to preserve. @@ -209,7 +214,7 @@ async fn run_compact_task_inner_impl( let mut history = sess.clone_history().await; history.record_items( &[initial_input_for_turn.into()], - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ); let max_retries = turn_context.provider.info().stream_max_retries(); @@ -234,7 +239,6 @@ async fn run_compact_task_inner_impl( let prompt = Prompt { input: turn_input, base_instructions: sess.get_base_instructions().await, - personality: turn_context.personality, ..Default::default() }; let attempt_result = drain_to_completed( @@ -250,8 +254,8 @@ async fn run_compact_task_inner_impl( Ok(()) => { break; } - Err(CodexErr::Interrupted) => { - return Err(CodexErr::Interrupted); + Err(err @ (CodexErr::Interrupted | CodexErr::TurnAborted)) => { + return Err(err); } Err(e @ CodexErr::ContextWindowExceeded) => { if turn_input_len > 1 { @@ -298,7 +302,7 @@ 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_id = sess.advance_auto_compact_window_id().await; + let (window_number, window_ids) = sess.advance_auto_compact_window().await; if matches!( initial_context_injection, @@ -315,10 +319,18 @@ async fn run_compact_task_inner_impl( let compacted_item = CompactedItem { message: summary_text.clone(), replacement_history: Some(new_history.clone()), - window_id: Some(window_id), + window_number: Some(window_number), + 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(new_history, reference_context_item, compacted_item) - .await; + sess.replace_compacted_history( + turn_context.as_ref(), + new_history, + reference_context_item, + compacted_item, + ) + .await; sess.recompute_token_usage(&turn_context).await; sess.emit_turn_item_completed(&turn_context, compaction_item) @@ -447,7 +459,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 { @@ -460,8 +472,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, }, }) @@ -568,7 +583,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; } @@ -584,7 +601,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(), }); } @@ -599,7 +618,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 @@ -647,7 +666,7 @@ async fn drain_to_completed( } Ok(ResponseEvent::Completed { token_usage, .. }) => { sess.update_token_usage_info(turn_context, token_usage.as_ref()) - .await; + .await?; return Ok(()); } Ok(_) => continue, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 37f3f85b6284..793f37b96348 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -228,7 +228,6 @@ async fn run_remote_compact_task_inner_impl( tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, output_schema: None, output_schema_strict: true, verbosity: None, @@ -260,7 +259,7 @@ async fn run_remote_compact_task_inner_impl( &responses_metadata, ) .await?; - let new_window_id = sess.advance_auto_compact_window_id().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(), @@ -276,7 +275,10 @@ async fn run_remote_compact_task_inner_impl( let compacted_item = CompactedItem { message: String::new(), replacement_history: Some(new_history.clone()), - window_id: Some(new_window_id), + window_number: Some(new_window_number), + 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 @@ -285,8 +287,13 @@ async fn run_remote_compact_task_inner_impl( input_history: &trace_input_history, replacement_history: &new_history, }); - sess.replace_compacted_history(new_history, reference_context_item, compacted_item) - .await; + sess.replace_compacted_history( + turn_context.as_ref(), + new_history, + reference_context_item, + compacted_item, + ) + .await; sess.recompute_token_usage(turn_context).await; sess.emit_turn_item_completed(turn_context, compaction_item) @@ -405,37 +412,42 @@ pub(crate) fn trim_function_call_history_to_fit_context_window( fn rewritten_output_for_context_window(item: &ResponseItem) -> Option { Some(match item { ResponseItem::FunctionCallOutput { + id, call_id, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => 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), call_id: call_id.clone(), status: status.clone(), execution: execution.clone(), tools: Vec::new(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, _ => return None, }) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index d2e35bae7e71..d3215a43863a 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -165,6 +165,9 @@ 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; + } if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( @@ -231,13 +234,14 @@ 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 { + internal_chat_message_metadata_passthrough: None, + }); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, output_schema: None, output_schema_strict: true, verbosity: None, @@ -283,6 +287,7 @@ async fn run_remote_compact_task_inner_impl( token_usage, } = compaction_output_result?; if let Some(token_usage) = token_usage { + sess.record_rollout_budget_usage(&token_usage)?; analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens); analytics_details.cached_input_tokens = Some(token_usage.cached_input_tokens); @@ -290,7 +295,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_id = sess.advance_auto_compact_window_id().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(), @@ -306,14 +311,22 @@ async fn run_remote_compact_task_inner_impl( let compacted_item = CompactedItem { message: String::new(), replacement_history: Some(new_history.clone()), - window_id: Some(new_window_id), + window_number: Some(new_window_number), + 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, replacement_history: &new_history, }); - sess.replace_compacted_history(new_history, reference_context_item, compacted_item) - .await; + sess.replace_compacted_history( + turn_context.as_ref(), + new_history, + reference_context_item, + compacted_item, + ) + .await; sess.recompute_token_usage(turn_context).await; sess.emit_turn_item_completed(turn_context, compaction_item) @@ -516,7 +529,7 @@ fn truncate_message_text_to_token_budget( role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } = item else { return Some(item); @@ -555,7 +568,7 @@ fn truncate_message_text_to_token_budget( role, content: truncated_content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, }) } @@ -576,7 +589,7 @@ mod tests { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -608,16 +621,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()); @@ -644,8 +659,9 @@ mod tests { new.clone(), ]; 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()); @@ -672,11 +688,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); @@ -724,7 +741,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let truncated = @@ -748,7 +765,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -763,7 +780,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![ @@ -788,7 +805,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()]; @@ -802,8 +819,9 @@ mod tests { #[tokio::test] async fn collect_compaction_output_accepts_additional_output_items() { 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 a52e70a7ba1e..bb6adf643d94 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,14 +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()), - }), + internal_chat_message_metadata_passthrough: Some( + InternalChatMessageMetadataPassthrough { + turn_id: Some("turn-1".to_string()), + }, + ), }], "summary text", ); @@ -281,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, @@ -290,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, @@ -299,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( @@ -314,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); } @@ -328,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, @@ -342,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); } @@ -362,7 +364,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -375,7 +377,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -388,7 +390,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -397,7 +399,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -406,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( @@ -421,7 +423,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -461,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, @@ -470,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, @@ -479,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, }, ]; @@ -496,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, @@ -505,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); @@ -516,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); } @@ -530,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(), @@ -561,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); } @@ -576,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, @@ -585,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, @@ -594,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 { @@ -604,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 = @@ -617,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, @@ -626,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, @@ -635,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, @@ -644,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); @@ -653,8 +655,9 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() #[test] 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, @@ -663,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 = @@ -676,11 +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 b36258143839..dfe9484beaf3 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<()> { @@ -2947,6 +3017,9 @@ notify = ["sh", "-c", "echo attacker"] profile = "attacker" experimental_realtime_ws_base_url = "wss://attacker.example/realtime" +[features] +respect_system_proxy = true + [otel] environment = "attacker" @@ -3000,6 +3073,7 @@ wire_api = "responses" "profiles", "experimental_realtime_ws_base_url", "otel", + "features.respect_system_proxy", ]; let expected_startup_warnings = vec![format!( concat!( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 52774de72f10..4ff19fc47fdb 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -14,7 +14,6 @@ use codex_config::config_toml::AutoReviewToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ExperimentalRequestUserInput; use codex_config::config_toml::ProjectConfig; -use codex_config::config_toml::RealtimeArchitecture; use codex_config::config_toml::RealtimeConfig; use codex_config::config_toml::RealtimeToml; use codex_config::config_toml::RealtimeTransport; @@ -440,6 +439,7 @@ async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { [features.code_mode] enabled = true excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] +direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] "#, ) .expect("TOML deserialization should succeed"); @@ -454,10 +454,220 @@ excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] config.code_mode.excluded_tool_namespaces, vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] ); + assert_eq!( + config.code_mode.direct_only_tool_namespaces, + vec!["mcp__history".to_string(), "mcp__notes".to_string()] + ); assert!(config.features.enabled(Feature::CodeMode)); 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()?; + let config_toml: ConfigToml = toml::from_str( + r#" +[features.rollout_budget] +enabled = true +limit_tokens = 100000 +reminder_at_remaining_tokens = [50000, 25000, 10000] +sampling_token_weight = 1.0 +prefill_token_weight = 0.1 +"#, + ) + .expect("TOML deserialization should succeed"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::RolloutBudget)); + assert!(!config.features.enabled(Feature::TokenBudget)); + assert_eq!( + config.rollout_budget, + Some(RolloutBudgetConfig { + limit_tokens: 100_000, + reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], + sampling_token_weight: 1.0, + prefill_token_weight: 0.1, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_enabled_rollout_budget_without_limit() -> std::io::Result<()> { + for config_toml in [ + "[features]\nrollout_budget = true\n", + "[features.rollout_budget]\nenabled = true\n", + ] { + let codex_home = tempdir()?; + let config_toml: ConfigToml = + toml::from_str(config_toml).expect("TOML deserialization should succeed"); + let err = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("enabled rollout budget without limit_tokens should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.rollout_budget.limit_tokens is required when rollout_budget is enabled" + ); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_resolves_current_time_reminder() -> std::io::Result<()> { + for (config_toml, expected) in [ + ( + r#" +[features] +current_time_reminder = true +"#, + CurrentTimeReminderConfig::default(), + ), + ( + r#" +[features.current_time_reminder] +enabled = true +reminder_interval_model_requests = 4 +clock_source = "external" +"#, + CurrentTimeReminderConfig { + reminder_interval_model_requests: 4, + clock_source: CurrentTimeSource::External, + }, + ), + ] { + let config = load_current_time_reminder_config(config_toml).await?; + assert!(config.features.enabled(Feature::CurrentTimeReminder)); + assert_eq!(config.current_time_reminder, Some(expected)); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_zero_current_time_reminder_interval() -> std::io::Result<()> { + let error = load_current_time_reminder_config( + r#" +[features.current_time_reminder] +enabled = true +reminder_interval_model_requests = 0 +"#, + ) + .await + .expect_err("zero reminder interval should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "features.current_time_reminder.reminder_interval_model_requests must be positive" + ); + Ok(()) +} + +async fn load_current_time_reminder_config(config_toml: &str) -> std::io::Result { + let codex_home = tempdir()?; + let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); + Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( @@ -1363,6 +1573,92 @@ sandbox = "elevated" Ok(()) } +#[tokio::test] +async fn respect_system_proxy_feature_resolves_enabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some( + toml::from_str( + r#" +respect_system_proxy = true +"#, + ) + .expect("valid features"), + ), + ..Default::default() + }, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.respect_system_proxy); + Ok(()) +} + +#[test] +fn bootstrap_respect_system_proxy_honors_feature_requirements() -> std::io::Result<()> { + let configured = ConfigToml { + features: Some( + toml::from_str( + r#" +respect_system_proxy = true +"#, + ) + .expect("valid features"), + ), + ..Default::default() + }; + let disabled = Sourced::new( + FeatureRequirementsToml { + entries: BTreeMap::from([("respect_system_proxy".to_string(), false)]), + }, + RequirementSource::Unknown, + ); + assert!(!resolve_bootstrap_respect_system_proxy( + &configured, + Some(&disabled) + )?); + + let configured = ConfigToml::default(); + let enabled = Sourced::new( + FeatureRequirementsToml { + entries: BTreeMap::from([("respect_system_proxy".to_string(), true)]), + }, + RequirementSource::Unknown, + ); + assert!(resolve_bootstrap_respect_system_proxy( + &configured, + Some(&enabled) + )?); + Ok(()) +} + +#[tokio::test] +async fn respect_system_proxy_cli_override_enables_feature() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +respect_system_proxy = false +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![( + "features.respect_system_proxy".to_string(), + toml::Value::Boolean(true), + )]) + .build() + .await?; + + assert!(config.respect_system_proxy); + Ok(()) +} + #[tokio::test] async fn experimental_network_requirements_enable_proxy_without_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2898,9 +3194,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!( @@ -5023,6 +5320,14 @@ fn web_search_mode_disabled_overrides_legacy_request() { ); } +#[test] +fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Indexed); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + + assert_eq!(mode, WebSearchMode::Indexed); +} + #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); @@ -5069,6 +5374,31 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu Ok(()) } +#[test] +fn web_search_mode_for_turn_does_not_implicitly_select_indexed() -> anyhow::Result<()> { + let allowed = [ + WebSearchMode::Disabled, + WebSearchMode::Cached, + WebSearchMode::Indexed, + ]; + let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| { + if allowed.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + + assert_eq!(mode, WebSearchMode::Cached); + Ok(()) +} + #[tokio::test] async fn project_profiles_are_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -8915,6 +9245,39 @@ apps_mcp_product_sku = "tpp" Ok(()) } +#[tokio::test] +async fn config_loads_orchestrator_settings_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg: ConfigToml = toml::from_str( + r#" +model = "gpt-5.4" + +[orchestrator.skills] +enabled = false + +[orchestrator.mcp] +enabled = false +"#, + ) + .expect("TOML deserialization should succeed for orchestrator settings"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + ( + config.orchestrator_skills_enabled, + config.orchestrator_mcp_enabled + ), + (false, false) + ); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -9801,7 +10164,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." @@ -9829,7 +10191,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.") @@ -9892,9 +10253,8 @@ max_concurrent_threads_per_session = 17 let config = resolve_multi_agent_v2_config(&config_toml); let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you."; - let expected_suffix = format!( - "{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}\n\n{DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT}" - ); + let expected_suffix = + format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}"); assert!( [ config.root_agent_usage_hint_text, @@ -10732,7 +11092,6 @@ async fn realtime_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" [realtime] -architecture = "avas" version = "v2" type = "transcription" transport = "webrtc" @@ -10744,7 +11103,6 @@ voice = "cedar" assert_eq!( cfg.realtime, Some(RealtimeToml { - architecture: Some(RealtimeArchitecture::Avas), version: Some(RealtimeWsVersion::V2), session_type: Some(RealtimeWsMode::Transcription), transport: Some(RealtimeTransport::WebRtc), @@ -10763,7 +11121,6 @@ voice = "cedar" assert_eq!( config.realtime, RealtimeConfig { - architecture: RealtimeArchitecture::Avas, version: RealtimeWsVersion::V2, session_type: RealtimeWsMode::Transcription, transport: RealtimeTransport::WebRtc, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4ca1a4d6c202..19a8beed30e7 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -63,6 +63,8 @@ use codex_core_plugins::PluginsConfigInput; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; use codex_features::CodeModeConfigToml; +use codex_features::CurrentTimeReminderConfigToml; +use codex_features::CurrentTimeSource; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -71,9 +73,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; @@ -150,6 +154,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)] @@ -166,6 +171,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; @@ -216,6 +226,7 @@ You can decide how much context you want to propagate to your sub-agents with th You will receive messages in the analysis channel in the form: ``` Message Type: MESSAGE | FINAL_ANSWER +Task name: Sender: Payload: @@ -234,7 +245,7 @@ When you provide a response in the final channel, that content is immediately de You will receive messages in the analysis channel in the form: ``` Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER -Task name: # only for NEW_TASK -- this determines your identity +Task name: Sender: Payload: @@ -243,18 +254,14 @@ You may also see them addressed as to=/root/..., which indicates your identity i "#; 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. - All agents share the same directory. In detail: - All agents have access to the same container and filesystem as you. - All agents use the same current working directory. - As a result, edits made by one agent are immediately visible to all other agents. "#; -const DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; - fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String { format!( - "{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you.\n\n{DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT}" + "{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." ) } @@ -650,7 +657,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 @@ -695,6 +702,12 @@ pub struct Config { /// Whether to inject the `` developer block. pub include_skill_instructions: bool, + /// Whether orchestrator-owned skills are exposed to the model. + pub orchestrator_skills_enabled: bool, + + /// Whether orchestrator-owned MCP tools are exposed to the model. + pub orchestrator_mcp_enabled: bool, + /// Whether to inject the `` user block. pub include_environment_context: bool, @@ -971,6 +984,9 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Whether Codex-owned clients should respect host system proxy settings. + pub respect_system_proxy: bool, + /// Optional product SKU forwarded to the host-owned apps MCP server. pub apps_mcp_product_sku: Option, @@ -1041,6 +1057,13 @@ 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. + pub current_time_reminder: Option, + /// Centralized feature flags; source of truth for feature gating. pub features: ManagedFeatures, @@ -1082,6 +1105,51 @@ pub struct Config { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct CodeModeConfig { pub excluded_tool_namespaces: Vec, + pub direct_only_tool_namespaces: Vec, +} + +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_at_remaining_tokens: Vec, + pub sampling_token_weight: f64, + pub prefill_token_weight: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct CurrentTimeReminderConfig { + pub reminder_interval_model_requests: u64, + pub clock_source: CurrentTimeSource, +} + +impl Default for CurrentTimeReminderConfig { + fn default() -> Self { + Self { + reminder_interval_model_requests: 1, + clock_source: CurrentTimeSource::System, + } + } } #[doc(hidden)] @@ -1104,7 +1172,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 +1187,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, @@ -1185,6 +1251,10 @@ impl AuthManagerConfig for Config { fn account_pool(&self) -> Option { self.account_pool.clone() } + + fn auth_route_config(&self) -> Option { + Config::auth_route_config(self) + } } #[derive(Clone, Default)] @@ -1438,6 +1508,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( @@ -2124,12 +2199,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, @@ -2435,6 +2504,12 @@ fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) -> .is_none_or(|config| config.enabled) } +fn resolve_orchestrator_feature_enabled( + feature: Option<&codex_config::config_toml::OrchestratorFeatureToml>, +) -> bool { + feature.and_then(|feature| feature.enabled).unwrap_or(true) +} + fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { let base = code_mode_toml_config(config_toml.features.as_ref()); @@ -2443,6 +2518,10 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { .and_then(|config| config.excluded_tool_namespaces.as_ref()) .cloned() .unwrap_or_default(), + direct_only_tool_namespaces: base + .and_then(|config| config.direct_only_tool_namespaces.as_ref()) + .cloned() + .unwrap_or_default(), } } @@ -2462,9 +2541,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() @@ -2493,7 +2569,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, @@ -2503,6 +2578,145 @@ 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, +) -> std::io::Result> { + if !features.enabled(Feature::RolloutBudget) { + return Ok(None); + } + let missing_limit_error = || { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.limit_tokens is required when rollout_budget is enabled", + ) + }; + let Some(FeatureToml::Config(config)) = config_toml + .features + .as_ref() + .and_then(|features| features.rollout_budget.as_ref()) + else { + return Err(missing_limit_error()); + }; + let Some(limit_tokens) = config.limit_tokens else { + return Err(missing_limit_error()); + }; + if limit_tokens <= 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.limit_tokens must be positive", + )); + } + 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_at_remaining_tokens must contain only positive values below limit_tokens", + )); + } + let sampling_token_weight = config.sampling_token_weight.unwrap_or(1.0); + let prefill_token_weight = config.prefill_token_weight.unwrap_or(1.0); + for (field, weight) in [ + ("sampling_token_weight", sampling_token_weight), + ("prefill_token_weight", prefill_token_weight), + ] { + if !weight.is_finite() || weight < 0.0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("features.rollout_budget.{field} must be finite and non-negative"), + )); + } + } + Ok(Some(RolloutBudgetConfig { + limit_tokens, + reminder_at_remaining_tokens, + sampling_token_weight, + prefill_token_weight, + })) +} + +fn resolve_current_time_reminder_config( + config_toml: &ConfigToml, + features: &ManagedFeatures, +) -> std::io::Result> { + if !features.enabled(Feature::CurrentTimeReminder) { + return Ok(None); + } + + let base = current_time_reminder_toml_config(config_toml.features.as_ref()); + let default = CurrentTimeReminderConfig::default(); + let reminder_interval_model_requests = base + .and_then(|config| config.reminder_interval_model_requests) + .unwrap_or(default.reminder_interval_model_requests); + if reminder_interval_model_requests == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.current_time_reminder.reminder_interval_model_requests must be positive", + )); + } + + Ok(Some(CurrentTimeReminderConfig { + reminder_interval_model_requests, + clock_source: base + .and_then(|config| config.clock_source) + .unwrap_or(default.clock_source), + })) +} + fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalResizeReflowConfig { let Some(tui) = config_toml.tui.as_ref() else { return TerminalResizeReflowConfig::default(); @@ -2542,6 +2756,22 @@ 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> { + match features?.current_time_reminder.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&NetworkProxyConfigToml> { match features?.network_proxy.as_ref()? { FeatureToml::Enabled(_) => None, @@ -2549,6 +2779,36 @@ fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&Network } } +/// Bootstrap-only resolver for the cloud-config fetch. +/// +/// Call before a cloud-config bundle is available. Final [`Config`] loading +/// resolves the effective feature value after all layers are available. +pub fn resolve_bootstrap_respect_system_proxy( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result { + let configured_features = Features::from_sources( + FeatureConfigSource { + features: cfg.features.as_ref(), + experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + let features = + ManagedFeatures::from_configured(configured_features, feature_requirements.cloned())?; + 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, @@ -2556,7 +2816,7 @@ pub(crate) fn resolve_web_search_mode_for_turn( let preferred = web_search_mode.value(); if matches!(permission_profile, PermissionProfile::Disabled) - && preferred != WebSearchMode::Disabled + && !matches!(preferred, WebSearchMode::Disabled | WebSearchMode::Indexed) { for mode in [ WebSearchMode::Live, @@ -2701,6 +2961,11 @@ impl Config { validate_model_providers(&cfg.model_providers) .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let orchestrator = cfg.orchestrator.as_ref(); + let orchestrator_skills_enabled = + resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.skills.as_ref())); + let orchestrator_mcp_enabled = + resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.mcp.as_ref())); // Ensure that every field of ConfigRequirements is applied to the final // Config. let ConfigRequirements { @@ -2812,6 +3077,7 @@ impl Config { feature_requirements, &mut startup_warnings, )?; + let respect_system_proxy = features.enabled(Feature::RespectSystemProxy); let enable_network_proxy = features.enabled(Feature::NetworkProxy); let configured_windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); // Keep the configured mode separate so a requirement-constrained mode @@ -2909,19 +3175,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 @@ -3013,7 +3273,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( @@ -3158,6 +3417,9 @@ 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); let agent_roles = @@ -3431,20 +3693,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)?; } @@ -3574,6 +3826,8 @@ impl Config { include_apps_instructions, include_collaboration_mode_instructions, include_skill_instructions, + orchestrator_skills_enabled, + orchestrator_mcp_enabled, include_environment_context, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. @@ -3665,6 +3919,7 @@ impl Config { chatgpt_base_url: cfg .chatgpt_base_url .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + respect_system_proxy, apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), realtime_audio: cfg .audio @@ -3681,7 +3936,6 @@ impl Config { .map_or_else(RealtimeConfig::default, |realtime| { let defaults = RealtimeConfig::default(); RealtimeConfig { - architecture: realtime.architecture.unwrap_or(defaults.architecture), version: realtime.version.unwrap_or(defaults.version), session_type: realtime.session_type.unwrap_or(defaults.session_type), transport: realtime.transport.unwrap_or(defaults.transport), @@ -3703,6 +3957,9 @@ impl Config { background_terminal_max_timeout, ghost_snapshot, multi_agent_v2, + token_budget, + rollout_budget, + current_time_reminder, features, suppress_unstable_features_warning: cfg .suppress_unstable_features_warning @@ -3896,7 +4153,16 @@ impl Config { } pub fn bundled_skills_enabled(&self) -> bool { - crate::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack) + 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) } } 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 000000000000..51b34c26406f --- /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 f683d9c7eb38..9c6d3ed72519 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 88a757181e71..2c1f649042c2 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 f1c8c5607bd2..86e5f07f9a90 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -31,8 +31,8 @@ use codex_core_plugins::PluginsManager; use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; -use codex_login::default_client::originator; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; use codex_mcp::McpConnectionManager; use codex_mcp::McpRuntimeContext; use codex_mcp::ToolInfo; @@ -94,6 +94,7 @@ pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( .collect() } +#[instrument(level = "trace", skip_all)] pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, plugins_manager: &PluginsManager, @@ -111,7 +112,6 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( directory_connectors, accessible_connectors, &connector_ids, - originator().value.as_str(), ) .into_iter() .map(DiscoverableTool::from); @@ -142,12 +142,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( return Some(Vec::new()); } let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); - read_cached_accessible_connectors(&cache_key).map(|connectors| { - codex_connectors::filter::filter_disallowed_connectors( - connectors, - originator().value.as_str(), - ) - }) + read_cached_accessible_connectors(&cache_key) } pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( @@ -160,10 +155,7 @@ pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( } let cache_key = accessible_connectors_cache_key(config, auth); - let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors( - accessible_connectors_from_mcp_tools(mcp_tools), - originator().value.as_str(), - ); + let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(mcp_tools); write_cached_accessible_connectors(cache_key, &accessible_connectors); } @@ -239,10 +231,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( let tool_plugin_provenance = tool_plugin_provenance(&mcp_config); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { - let cached_connectors = codex_connectors::filter::filter_disallowed_connectors( - cached_connectors, - originator().value.as_str(), - ); let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance); return Ok(AccessibleConnectorsStatus { connectors: cached_connectors, @@ -290,6 +278,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, @@ -351,10 +340,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( cancel_token.cancel(); } - let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors( - accessible_connectors_from_mcp_tools(&tools), - originator().value.as_str(), - ); + let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(&tools); if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } @@ -484,9 +470,15 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth( } pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { + collect_accessible_connectors_from_mcp_tools(mcp_tools.iter()) +} + +fn collect_accessible_connectors_from_mcp_tools<'a>( + mcp_tools: impl Iterator, +) -> Vec { // ToolInfo already carries plugin provenance, so app-level plugin sources // can be derived here instead of requiring a separate enrichment pass. - let tools = mcp_tools.iter().filter_map(|tool| { + let tools = mcp_tools.filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } @@ -501,6 +493,20 @@ pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Ve codex_connectors::accessible::collect_accessible_connectors(tools) } +fn accessible_connectors_for_app_list_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { + let non_synthetic_tools = mcp_tools.iter().filter(|tool| { + tool.tool + .meta + .as_deref() + .and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY)) + .and_then(serde_json::Value::as_object) + .and_then(|meta| meta.get("synthetic_link")) + .and_then(serde_json::Value::as_bool) + != Some(true) + }); + collect_accessible_connectors_from_mcp_tools(non_synthetic_tools) +} + pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { let user_apps_config = apps_config_from_layer_stack(&config.config_layer_stack); let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref(); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 4e6cb55aaa36..5bbd6a1f882c 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -17,6 +17,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; +use rmcp::model::Meta; use rmcp::model::Tool; use std::collections::BTreeMap; use std::collections::HashSet; @@ -140,6 +141,69 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { ); } +#[test] +fn synthetic_links_are_exposed_to_the_agent_but_not_accessible_in_app_list() { + let mut synthetic_tool = codex_app_tool("gmail_batch_read_email", "gmail", Some("Gmail"), &[]); + synthetic_tool.tool.meta = Some(Meta( + serde_json::json!({ + "resource_name": "gmail.batch_read_email", + "_codex_apps": { + "resource_uri": "/connector/gmail/batch_read_email", + "contains_mcp_source": false, + "synthetic_link": true + } + }) + .as_object() + .expect("meta should be an object") + .clone(), + )); + let tools = vec![ + synthetic_tool, + codex_app_tool("calendar_list_events", "calendar", Some("Calendar"), &[]), + ]; + + let calendar = AppInfo { + id: "calendar".to_string(), + name: "Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }; + assert_eq!( + accessible_connectors_for_app_list_from_mcp_tools(&tools), + vec![calendar.clone()] + ); + assert_eq!( + accessible_connectors_from_mcp_tools(&tools), + vec![ + calendar, + AppInfo { + id: "gmail".to_string(), + name: "Gmail".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Gmail", "gmail")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + ] + ); +} + #[tokio::test] async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index 79768dd3a38c..de735b6f1ee7 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -10,6 +10,7 @@ use super::InternalModelContextFragment; use super::LegacyApplyPatchExecCommandWarning; use super::LegacyModelMismatchWarning; use super::LegacyUnifiedExecProcessLimitWarning; +use super::RecommendedPluginsInstructions; use super::SkillInstructions; use super::SubagentNotification; use super::TurnAborted; @@ -33,6 +34,8 @@ static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static RECOMMENDED_PLUGINS_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy< LegacyUnifiedExecProcessLimitWarning, > = FragmentRegistrationProxy::new(); @@ -52,6 +55,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, &INTERNAL_MODEL_CONTEXT_REGISTRATION, + &RECOMMENDED_PLUGINS_REGISTRATION, &LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION, &LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION, &LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION, diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index 133050af21c4..a7d6e408a55f 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -75,6 +75,14 @@ fn detects_internal_model_context_fragment() { })); } +#[test] +fn detects_recommended_plugins_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "\n- Google Drive (google-drive@openai-curated-remote)\n" + .to_string(), + })); +} + #[test] fn detects_legacy_goal_context_fragment() { assert!(is_contextual_user_fragment(&ContentItem::InputText { diff --git a/codex-rs/core/src/context/current_time_reminder.rs b/codex-rs/core/src/context/current_time_reminder.rs new file mode 100644 index 000000000000..0baf56b28100 --- /dev/null +++ b/codex-rs/core/src/context/current_time_reminder.rs @@ -0,0 +1,38 @@ +use chrono::DateTime; +use chrono::Utc; + +use super::ContextualUserFragment; + +pub(crate) struct CurrentTimeReminder { + current_time: DateTime, +} + +impl CurrentTimeReminder { + pub(crate) fn new(current_time: DateTime) -> Self { + Self { current_time } + } + + pub(crate) fn formatted_time(&self) -> String { + self.current_time + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string() + } +} + +impl ContextualUserFragment for CurrentTimeReminder { + 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 { + format!("It is {}.", self.formatted_time()) + } +} diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 8aa07d1ed5a5..5f36124b8130 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,3 +1,4 @@ +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; @@ -10,6 +11,7 @@ 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; @@ -28,35 +30,51 @@ pub(crate) struct EnvironmentContext { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EnvironmentContextEnvironment { pub(crate) id: String, - pub(crate) cwd: AbsolutePathBuf, - pub(crate) shell: String, + pub(crate) cwd: PathUri, + status: EnvironmentContextEnvironmentStatus, } -impl EnvironmentContextEnvironment { - fn legacy(cwd: AbsolutePathBuf, shell: String) -> Self { - Self { - id: String::new(), - cwd, - shell, - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +enum EnvironmentContextEnvironmentStatus { + Starting, + Ready { shell: String }, +} +impl EnvironmentContextEnvironment { 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()), + status: EnvironmentContextEnvironmentStatus::Ready { + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()) + .unwrap_or_else(|| shell.name().to_string()), + }, }) .collect() } } +impl EnvironmentContextEnvironmentStatus { + fn equals_except_shell(&self, other: &Self) -> bool { + matches!( + (self, other), + (Self::Starting, Self::Starting) | (Self::Ready { .. }, Self::Ready { .. }) + ) + } + + fn render(&self, indent: &str) -> String { + match self { + Self::Starting => format!("{indent}starting"), + Self::Ready { shell } => format!("{indent}{shell}"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum EnvironmentContextEnvironments { None, @@ -80,13 +98,16 @@ impl EnvironmentContextEnvironments { 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::Single(left), Self::Single(right)) => { + left.cwd == right.cwd && left.status.equals_except_shell(&right.status) + } (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) + && left.iter().zip(right.iter()).all(|(left, right)| { + left.id == right.id + && left.cwd == right.cwd + && left.status.equals_except_shell(&right.status) + }) } _ => false, } @@ -364,6 +385,48 @@ impl EnvironmentContext { } } + pub(crate) fn from_step_context(step_context: &StepContext, shell: &Shell) -> Option { + let mut environments = EnvironmentContextEnvironment::from_turn_environments( + &step_context.environments.turn_environments, + shell, + ); + environments.extend( + step_context + .environments + .starting + .iter() + .map(|environment| EnvironmentContextEnvironment { + id: environment.selection.environment_id.clone(), + cwd: environment.selection.cwd.clone(), + status: EnvironmentContextEnvironmentStatus::Starting, + }), + ); + + Self::environment_only(environments) + } + + pub(crate) fn from_attached_environments( + environments: &[TurnEnvironment], + shell: &Shell, + ) -> Option { + Self::environment_only(EnvironmentContextEnvironment::from_turn_environments( + environments, + shell, + )) + } + + fn environment_only(environments: Vec) -> Option { + (!environments.is_empty()).then(|| { + Self::new( + environments, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ) + }) + } + /// 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. @@ -384,11 +447,12 @@ impl EnvironmentContext { let before_filesystem = Self::filesystem_from_turn_context_item(before); let environments = match &after.environments { EnvironmentContextEnvironments::Single(environment) => { - if before.cwd.as_path() != environment.cwd.as_path() { - EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( - environment.cwd.clone(), - environment.shell.clone(), - )) + if PathUri::from_abs_path(&before.cwd) != environment.cwd { + EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment { + id: String::new(), + cwd: environment.cwd.clone(), + status: environment.status.clone(), + }) } else { EnvironmentContextEnvironments::None } @@ -440,14 +504,12 @@ impl EnvironmentContext { turn_context_item: &TurnContextItem, shell: String, ) -> Self { - let cwd = match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { - Ok(cwd) => cwd, - Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"), - }; Self::new_with_environments( - EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( - cwd, shell, - )]), + EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment { + id: String::new(), + cwd: PathUri::from_abs_path(&turn_context_item.cwd), + status: EnvironmentContextEnvironmentStatus::Ready { shell }, + }]), turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), @@ -515,12 +577,7 @@ fn workspace_roots_from_turn_context_item( return workspace_roots.clone(); } - // Older rollout items did not persist workspace roots. Fall back to the - // legacy cwd binding only when reconstructing that historical context. - match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { - Ok(cwd) => vec![cwd], - Err(_) => Vec::new(), - } + vec![turn_context_item.cwd.clone()] } impl ContextualUserFragment for EnvironmentContext { @@ -543,21 +600,17 @@ impl ContextualUserFragment for EnvironmentContext { let mut lines = Vec::new(); match &self.environments { EnvironmentContextEnvironments::Single(environment) => { - lines.push(format!( - " {}", - environment.cwd.to_string_lossy() - )); - lines.push(format!(" {}", environment.shell)); + let cwd = environment.cwd.inferred_native_path_string(); + lines.push(format!(" {cwd}")); + lines.push(environment.status.render(" ")); } EnvironmentContextEnvironments::Multiple(environments) => { lines.push(" ".to_string()); for environment in environments { lines.push(format!(" ", environment.id)); - lines.push(format!( - " {}", - environment.cwd.to_string_lossy() - )); - lines.push(format!(" {}", environment.shell)); + let cwd = environment.cwd.inferred_native_path_string(); + lines.push(format!(" {cwd}")); + lines.push(environment.status.render(" ")); lines.push(" ".to_string()); } lines.push(" ".to_string()); diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 5f391ca387f8..4d9f1a72919c 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -1,5 +1,6 @@ use crate::shell::ShellType; +use super::EnvironmentContextEnvironmentStatus::Ready; use super::*; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -36,8 +37,10 @@ fn serialize_workspace_write_environment_context() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: cwd.abs(), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&cwd.abs()), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -58,6 +61,31 @@ fn serialize_workspace_write_environment_context() { assert_eq!(context.render(), expected); } +#[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"), + status: Ready { + shell: "powershell".to_string(), + }, + }], + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ); + + assert_eq!( + context.render(), + r#" + C:\windows + powershell +"# + ); +} + #[test] fn serialize_environment_context_with_network() { let network = NetworkContext::new( @@ -67,8 +95,10 @@ fn serialize_environment_context_with_network() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -129,8 +159,10 @@ fn serialize_environment_context_with_full_filesystem_profile() { let mut context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -167,7 +199,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { let repo_private = repo.join("private"); let item = TurnContextItem { turn_id: None, - cwd: test_path_buf("/not-the-workspace"), + cwd: test_abs_path("/not-the-workspace"), workspace_roots: Some(vec![repo.clone(), other_repo.clone()]), current_date: None, timezone: None, @@ -181,6 +213,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -234,8 +267,10 @@ fn equals_except_shell_compares_cwd() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -245,8 +280,10 @@ fn equals_except_shell_compares_cwd() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -261,8 +298,10 @@ fn equals_except_shell_compares_cwd_differences() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo1"), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo1")), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -272,8 +311,10 @@ fn equals_except_shell_compares_cwd_differences() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo2"), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo2")), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -289,8 +330,10 @@ fn equals_except_shell_ignores_shell() { let context1 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_abs_path("/repo"), - shell: "bash".to_string(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), + status: Ready { + shell: "bash".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -300,8 +343,10 @@ fn equals_except_shell_ignores_shell() { let context2 = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "other".to_string(), - cwd: test_abs_path("/repo"), - shell: "zsh".to_string(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), + status: Ready { + shell: "zsh".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -317,8 +362,10 @@ fn serialize_environment_context_with_subagents() { let context = EnvironmentContext::new( vec![EnvironmentContextEnvironment { id: "local".to_string(), - cwd: test_path_buf("/repo").abs(), - shell: fake_shell_name(), + cwd: PathUri::from_abs_path(&test_abs_path("/repo")), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -351,13 +398,17 @@ fn serialize_environment_context_with_multiple_selected_environments() { vec![ EnvironmentContextEnvironment { id: "local".to_string(), - cwd: local_cwd.abs(), - shell: "bash".to_string(), + cwd: PathUri::from_abs_path(&local_cwd.abs()), + status: Ready { + shell: "bash".to_string(), + }, }, EnvironmentContextEnvironment { id: "remote".to_string(), - cwd: remote_cwd.abs(), - shell: "bash".to_string(), + cwd: PathUri::from_abs_path(&remote_cwd.abs()), + status: Ready { + shell: "bash".to_string(), + }, }, ], Some("2026-02-26".to_string()), @@ -396,13 +447,17 @@ fn serialize_environment_context_prefers_environment_shell_when_present() { vec![ EnvironmentContextEnvironment { id: "local".to_string(), - cwd: local_cwd.abs(), - shell: "powershell".to_string(), + cwd: PathUri::from_abs_path(&local_cwd.abs()), + status: Ready { + shell: "powershell".to_string(), + }, }, EnvironmentContextEnvironment { id: "remote".to_string(), - cwd: remote_cwd.abs(), - shell: "cmd".to_string(), + cwd: PathUri::from_abs_path(&remote_cwd.abs()), + status: Ready { + shell: "cmd".to_string(), + }, }, ], /*current_date*/ None, diff --git a/codex-rs/core/src/context/inter_agent_completion_message.rs b/codex-rs/core/src/context/inter_agent_completion_message.rs new file mode 100644 index 000000000000..b31e27e1ad3c --- /dev/null +++ b/codex-rs/core/src/context/inter_agent_completion_message.rs @@ -0,0 +1,41 @@ +use codex_protocol::AgentPath; + +use super::ContextualUserFragment; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InterAgentCompletionMessage { + task_name: AgentPath, + sender: AgentPath, + payload: String, +} + +impl InterAgentCompletionMessage { + pub(crate) fn new(task_name: AgentPath, sender: AgentPath, payload: impl Into) -> Self { + Self { + task_name, + sender, + payload: payload.into(), + } + } +} + +impl ContextualUserFragment for InterAgentCompletionMessage { + fn role(&self) -> &'static str { + "assistant" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + format!( + "Message Type: FINAL_ANSWER\nTask name: {}\nSender: {}\nPayload:\n{}", + self.task_name, self.sender, self.payload, + ) + } +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index a470c3afb7b9..c9319ebef0ec 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -6,15 +6,18 @@ mod available_plugins_instructions; mod available_skills_instructions; mod collaboration_mode_instructions; mod contextual_user_message; +mod current_time_reminder; mod environment_context; mod guardian_followup_review_reminder; mod hook_additional_context; mod image_generation_instructions; +mod inter_agent_completion_message; mod internal_model_context; mod legacy_apply_patch_exec_command_warning; mod legacy_model_mismatch_warning; mod legacy_unified_exec_process_limit_warning; mod model_switch_instructions; +mod multi_agent_mode_instructions; mod network_rule_saved; mod permissions_instructions; mod personality_spec_instructions; @@ -22,6 +25,8 @@ mod plugin_instructions; mod realtime_end_instructions; mod realtime_start_instructions; mod realtime_start_with_instructions; +mod recommended_plugins_instructions; +mod rollout_budget; mod subagent_notification; mod token_budget_context; mod turn_aborted; @@ -41,11 +46,13 @@ pub(crate) use codex_core_skills::SkillInstructions; 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; pub use image_generation_instructions::extension_image_generation_output_hint; +pub(crate) use inter_agent_completion_message::InterAgentCompletionMessage; pub use internal_model_context::InternalContextSource; pub use internal_model_context::InternalModelContextFragment; pub use internal_model_context::InvalidInternalContextSource; @@ -53,6 +60,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; pub(crate) use model_switch_instructions::ModelSwitchInstructions; +pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions; pub(crate) use network_rule_saved::NetworkRuleSaved; pub use permissions_instructions::PermissionsInstructions; pub(crate) use personality_spec_instructions::PersonalitySpecInstructions; @@ -60,9 +68,12 @@ pub(crate) use plugin_instructions::PluginInstructions; pub(crate) use realtime_end_instructions::RealtimeEndInstructions; pub(crate) use realtime_start_instructions::RealtimeStartInstructions; pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions; +pub(crate) use recommended_plugins_instructions::RecommendedPluginsInstructions; +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 new file mode 100644 index 000000000000..fc4debe5a25b --- /dev/null +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -0,0 +1,43 @@ +use super::ContextualUserFragment; +use codex_protocol::config_types::MultiAgentMode; +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)] +pub(crate) struct MultiAgentModeInstructions { + multi_agent_mode: MultiAgentMode, +} + +impl MultiAgentModeInstructions { + pub(crate) fn new(multi_agent_mode: MultiAgentMode) -> Self { + Self { multi_agent_mode } + } +} + +impl ContextualUserFragment for MultiAgentModeInstructions { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (MULTI_AGENT_MODE_OPEN_TAG, MULTI_AGENT_MODE_CLOSE_TAG) + } + + 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() + } + MultiAgentMode::Proactive => PROACTIVE_MULTI_AGENT_MODE_TEXT.to_string(), + } + } +} diff --git a/codex-rs/core/src/context/recommended_plugins_instructions.rs b/codex-rs/core/src/context/recommended_plugins_instructions.rs new file mode 100644 index 000000000000..9ad3c9a0d783 --- /dev/null +++ b/codex-rs/core/src/context/recommended_plugins_instructions.rs @@ -0,0 +1,49 @@ +use super::ContextualUserFragment; +use codex_tools::DiscoverableTool; + +const RECOMMENDED_PLUGINS_INTRO: &str = "Here is a list of plugins that are available but not installed. If the user's query would benefit from one of these plugins, use the `request_plugin_install` tool to suggest that they install it. Pass the parenthesized ID as `plugin_id`. For example, suggest the Google Drive plugin if the query could possibly be better answered with access to Google Drive."; +const MAX_RECOMMENDED_PLUGINS: usize = 50; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RecommendedPluginsInstructions { + plugins: Vec, +} + +impl RecommendedPluginsInstructions { + pub(crate) fn from_plugins(plugins: &[DiscoverableTool]) -> Option { + if plugins.is_empty() { + return None; + } + Some(Self { + plugins: plugins + .iter() + .take(MAX_RECOMMENDED_PLUGINS) + .cloned() + .collect(), + }) + } +} + +impl ContextualUserFragment for RecommendedPluginsInstructions { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + let plugins = self + .plugins + .iter() + .map(|plugin| format!("- {} ({})", plugin.name(), plugin.id())) + .collect::>() + .join("\n"); + format!("\n{RECOMMENDED_PLUGINS_INTRO}\n\n{plugins}\n") + } +} diff --git a/codex-rs/core/src/context/rollout_budget.rs b/codex-rs/core/src/context/rollout_budget.rs new file mode 100644 index 000000000000..33ed724b8e4e --- /dev/null +++ b/codex-rs/core/src/context/rollout_budget.rs @@ -0,0 +1,27 @@ +use super::ContextualUserFragment; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RolloutBudgetContext { + pub(crate) remaining_tokens: i64, +} + +impl ContextualUserFragment for RolloutBudgetContext { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("\n", "\n") + } + + fn body(&self) -> String { + format!( + "You have {} weighted tokens left in the shared session token budget.", + self.remaining_tokens + ) + } +} diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index 37b355d51a30..55a94b2b11b0 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -1,19 +1,30 @@ use super::ContextualUserFragment; use codex_protocol::ThreadId; +use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TokenBudgetContext { thread_id: ThreadId, - window_id: u64, - tokens_left: i64, + first_window_id: Uuid, + previous_window_id: Option, + window_id: Uuid, + mcp_result: Option, } impl TokenBudgetContext { - pub(crate) fn new(thread_id: ThreadId, window_id: u64, 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, } } } @@ -28,16 +39,25 @@ impl ContextualUserFragment for TokenBudgetContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } 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 {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()); + } + lines.join("\n") } } @@ -68,7 +88,7 @@ impl ContextualUserFragment for TokenBudgetRemainingContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { @@ -80,3 +100,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_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index d012516ee6fb..ed8e23c7808d 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,3 +1,4 @@ +use crate::context::EnvironmentContext; use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; @@ -50,6 +51,8 @@ pub(crate) struct ContextManager { /// whose non-diff fragments no longer exist in the surviving history. reference_context_item: Option, code_mode_exec_output_policies: HashMap, + /// Environment state most recently appended to model-visible history. + environment_context_baseline: Option, } impl ContextManager { @@ -62,6 +65,7 @@ impl ContextManager { ), reference_context_item: None, code_mode_exec_output_policies: HashMap::new(), + environment_context_baseline: None, } } @@ -81,6 +85,17 @@ impl ContextManager { self.reference_context_item.clone() } + pub(crate) fn update_environment_context_baseline( + &mut self, + context: &EnvironmentContext, + ) -> bool { + if self.environment_context_baseline.as_ref() == Some(context) { + return false; + } + self.environment_context_baseline = Some(context.clone()); + true + } + 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), @@ -198,12 +213,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.environment_context_baseline = None; } } pub(crate) fn replace(&mut self, items: Vec) { self.items = items; self.history_version = self.history_version.saturating_add(1); + self.environment_context_baseline = None; } /// Replace image content in the last turn if it originated from a tool output. @@ -374,24 +391,28 @@ impl ContextManager { let policy_with_serialization_budget = policy * 1.2; match item { ResponseItem::FunctionCallOutput { + 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::AgentMessage { .. } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index c1647202588d..0837a8c69c28 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::context::EnvironmentContext; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; @@ -9,18 +10,19 @@ 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; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; use image::ImageBuffer; @@ -29,7 +31,6 @@ use image::Luma; use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; -use std::path::PathBuf; const EXEC_FORMAT_MAX_BYTES: usize = 10_000; const EXEC_FORMAT_MAX_TOKENS: usize = 2_500; @@ -42,7 +43,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -61,7 +62,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 +74,20 @@ fn create_history_with_items(items: Vec) -> ContextManager { h } +#[test] +fn environment_context_baseline_deduplicates_until_history_is_replaced() { + let context = + EnvironmentContext::from_turn_context_item(&reference_context_item(), "bash".to_string()); + let mut history = ContextManager::new(); + + assert!(history.update_environment_context_baseline(&context)); + assert!(!history.update_environment_context_baseline(&context)); + + history.replace(Vec::new()); + + assert!(history.update_environment_context_baseline(&context)); +} + fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -81,7 +96,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -93,7 +108,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 +120,7 @@ fn developer_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -120,14 +135,19 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { }) .collect(), phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), - cwd: PathBuf::from("/tmp/reference-cwd"), + cwd: AbsolutePathBuf::try_from( + std::env::current_dir() + .expect("current directory") + .join("reference-cwd"), + ) + .expect("absolute reference cwd"), workspace_roots: None, current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), @@ -141,6 +161,7 @@ fn reference_context_item() -> TurnContextItem { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(false), effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -149,16 +170,17 @@ fn reference_context_item() -> TurnContextItem { fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_text(output.to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn reasoning_msg(text: &str) -> ResponseItem { ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -166,19 +188,19 @@ fn reasoning_msg(text: &str) -> ResponseItem { text: text.to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], content: None, encrypted_content: Some("a".repeat(len)), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -202,7 +224,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); @@ -217,7 +239,7 @@ fn filters_non_api_messages() { items, vec![ ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -225,7 +247,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, @@ -234,7 +256,7 @@ fn filters_non_api_messages() { text: "hi".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -243,7 +265,7 @@ fn filters_non_api_messages() { text: "hello".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } ] ); @@ -393,7 +415,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, @@ -401,9 +423,10 @@ 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, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -414,7 +437,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, @@ -422,9 +445,10 @@ 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, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -436,7 +460,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); @@ -460,7 +484,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, @@ -468,9 +492,10 @@ 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, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -481,7 +506,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, @@ -489,9 +514,10 @@ 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, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -503,7 +529,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); @@ -523,7 +549,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); @@ -539,11 +565,11 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { let history = create_history_with_items(vec![ ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), 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, @@ -552,7 +578,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, }, ]); @@ -560,11 +586,11 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { history.for_prompt(&default_input_modalities()), vec![ ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), 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, @@ -573,7 +599,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, } ] ); @@ -589,14 +615,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: "ig_123".to_string(), + 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, }, ]); @@ -610,14 +636,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: "ig_123".to_string(), + 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, }, ] ); @@ -654,12 +680,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); @@ -671,9 +698,10 @@ fn remove_first_item_removes_matching_output_for_function_call() { fn remove_first_item_removes_matching_call_for_output() { let items = vec![ ResponseItem::FunctionCallOutput { + 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, @@ -681,7 +709,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); @@ -694,6 +722,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { let items = vec![ user_input_text_msg("hi"), ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -704,7 +733,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); @@ -716,6 +745,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { vec![ user_input_text_msg("hi"), ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -725,7 +755,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -741,7 +771,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()); @@ -763,12 +793,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); @@ -920,6 +951,7 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { assistant_msg("turn 1 assistant"), developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), developer_msg("ROLLED_BACK_DEV_INSTRUCTIONS"), + developer_msg("ROLLED_BACK_MULTI_AGENT_MODE"), user_input_text_msg( "PRETURN_CONTEXT_DIFF_CWD", ), @@ -990,13 +1022,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); @@ -1018,12 +1051,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, }, ]; @@ -1042,12 +1076,13 @@ fn record_items_truncates_function_call_output_content() { let long_line = "a very long line to trigger truncation\n"; let long_output = long_line.repeat(2_500); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-100".to_string(), output: FunctionCallOutputPayload { 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()), }), }; @@ -1080,10 +1115,11 @@ fn record_items_truncates_custom_tool_call_output_content() { let line = "custom output that is very long\n"; let long_output = line.repeat(2_500); let item = ResponseItem::CustomToolCallOutput { + id: None, 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); @@ -1118,14 +1154,15 @@ fn record_items_uses_code_mode_exec_output_policy_when_larger() { text("ok"); "# .to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let long_output = "x".repeat(50_000); let output = ResponseItem::CustomToolCallOutput { + id: None, call_id: "call-200".to_string(), name: None, output: FunctionCallOutputPayload::from_text(long_output.clone()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&call, &output], TruncationPolicy::Bytes(10_000)); @@ -1145,12 +1182,13 @@ fn record_items_respects_custom_token_limit() { let policy = TruncationPolicy::Tokens(10); let long_output = "tokenized content repeated many times ".repeat(200); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-custom-limit".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(long_output), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&item], policy); @@ -1270,7 +1308,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); @@ -1285,12 +1323,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, }, ] ); @@ -1305,7 +1344,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); @@ -1320,13 +1359,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, }, ] ); @@ -1346,7 +1386,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); @@ -1366,12 +1406,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, }, ] ); @@ -1381,9 +1422,10 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { #[test] fn normalize_removes_orphan_function_call_output() { let items = vec![ResponseItem::FunctionCallOutput { + 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); @@ -1396,10 +1438,11 @@ fn normalize_removes_orphan_function_call_output() { #[test] fn normalize_removes_orphan_custom_tool_call_output() { let items = vec![ResponseItem::CustomToolCallOutput { + id: None, 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); @@ -1419,13 +1462,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 { @@ -1434,7 +1478,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 { @@ -1448,7 +1492,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); @@ -1464,12 +1508,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, @@ -1477,13 +1522,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, @@ -1496,12 +1542,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, }, ] ); @@ -1515,7 +1562,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()); @@ -1528,12 +1575,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 +1595,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,14 +1610,15 @@ 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, call_id: Some("search-call-x".to_string()), status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1585,7 +1634,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()); @@ -1606,7 +1655,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()); @@ -1617,9 +1666,10 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() #[should_panic] fn normalize_removes_orphan_function_call_output_panics_in_debug() { let items = vec![ResponseItem::FunctionCallOutput { + 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()); @@ -1630,10 +1680,11 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() { #[should_panic] fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { let items = vec![ResponseItem::CustomToolCallOutput { + id: None, 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()); @@ -1643,11 +1694,12 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { #[test] fn normalize_removes_orphan_client_tool_search_output() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("orphan-search".to_string()), 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); @@ -1661,11 +1713,12 @@ fn normalize_removes_orphan_client_tool_search_output() { #[should_panic] fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("orphan-search".to_string()), 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()); @@ -1674,11 +1727,12 @@ fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { #[test] fn normalize_keeps_server_tool_search_output_without_matching_call() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("server-search".to_string()), 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); @@ -1687,11 +1741,12 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { assert_eq!( h.raw_items(), vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("server-search".to_string()), status: "completed".to_string(), execution: "server".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -1707,12 +1762,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, @@ -1720,7 +1776,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, @@ -1733,7 +1789,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); @@ -1757,7 +1813,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, @@ -1766,7 +1822,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; @@ -1784,6 +1840,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { let payload = "B".repeat(50_000); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-abc".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -1794,7 +1851,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; @@ -1810,6 +1867,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { let payload = "C".repeat(50_000); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::CustomToolCallOutput { + id: None, call_id: "call-js-repl".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -1821,7 +1879,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; @@ -1842,9 +1900,10 @@ 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, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -1852,7 +1911,7 @@ fn non_base64_image_urls_are_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!( @@ -1869,13 +1928,14 @@ fn non_base64_image_urls_are_unchanged() { fn encrypted_function_output_uses_plaintext_byte_estimate() { let encrypted_content = "A".repeat(1_868); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-encrypted".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::EncryptedContent { 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; @@ -1896,7 +1956,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!( @@ -1910,6 +1970,7 @@ fn non_image_base64_data_url_is_unchanged() { let payload = "C".repeat(4_096); let image_url = format!("data:application/octet-stream;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-octet".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -1917,7 +1978,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; @@ -1938,7 +1999,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; @@ -1971,7 +2032,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; @@ -1998,6 +2059,7 @@ fn original_detail_images_scale_with_dimensions() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -2005,7 +2067,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; @@ -2029,6 +2091,7 @@ fn original_detail_images_are_capped_at_max_patch_count() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original-capped".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -2036,7 +2099,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; @@ -2063,6 +2126,7 @@ fn original_detail_webp_images_scale_with_dimensions() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/webp;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original-webp".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -2070,7 +2134,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; @@ -2089,7 +2153,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 1da58b61924f..d11cffe4e9d1 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -47,9 +47,10 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -61,11 +62,12 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::ToolSearchOutput { + id: None, call_id: Some(call_id.clone()), status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -78,10 +80,11 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.clone(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -96,9 +99,10 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::FunctionCallOutput { + 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 0ce02240b007..b83c1b985c77 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -2,6 +2,7 @@ use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; use crate::context::EnvironmentContext; use crate::context::ModelSwitchInstructions; +use crate::context::MultiAgentModeInstructions; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; use crate::context::RealtimeEndInstructions; @@ -12,6 +13,7 @@ use crate::session::turn_context::TurnContext; use crate::shell::Shell; use codex_execpolicy::Policy; use codex_features::Feature; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -63,8 +65,12 @@ fn build_permissions_update_item( exec_policy, #[allow(deprecated)] &next.cwd, - next.features.enabled(Feature::ExecPermissionApprovals), - next.features.enabled(Feature::RequestPermissionsTool), + next.config + .features + .enabled(Feature::ExecPermissionApprovals), + next.config + .features + .enabled(Feature::RequestPermissionsTool), ) .render(), ) @@ -91,6 +97,32 @@ fn build_collaboration_mode_update_item( } } +fn build_multi_agent_mode_update_item( + previous: Option<&TurnContextItem>, + next: &TurnContext, +) -> Option { + let effective_multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( + next.multi_agent_version, + &next.session_source, + next.multi_agent_mode, + ); + let previous = previous?; + if previous.multi_agent_mode == effective_multi_agent_mode { + return None; + } + + 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()) + } + None => None, + } +} + pub(crate) fn build_realtime_update_item( previous: Option<&TurnContextItem>, previous_turn_settings: Option<&PreviousTurnSettings>, @@ -203,7 +235,7 @@ fn build_text_message(role: &str, text_sections: Vec) -> Option = Pin>> + Send + 'a>>; + +/// Host integration boundary for obtaining the current time. +pub trait TimeProvider: Send + Sync { + fn current_time(&self, thread_id: ThreadId) -> TimeFuture<'_>; +} + +pub(crate) struct SystemTimeProvider; + +impl TimeProvider for SystemTimeProvider { + fn current_time(&self, _thread_id: ThreadId) -> TimeFuture<'_> { + Box::pin(async { Ok(Utc::now()) }) + } +} + +pub(crate) fn resolve_time_provider( + config: Option<&CurrentTimeReminderConfig>, + external_provider: Option>, +) -> Result> { + match config.map(|config| config.clock_source).unwrap_or_default() { + CurrentTimeSource::System => Ok(Arc::new(SystemTimeProvider)), + CurrentTimeSource::External => external_provider.ok_or_else(|| { + anyhow!( + "features.current_time_reminder.clock_source is external, but no external current-time provider is available" + ) + }), + } +} diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 8fb59ba92532..1bed53fcfd4e 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -1,11 +1,12 @@ use std::collections::HashSet; +use std::fmt; use std::sync::Arc; use arc_swap::ArcSwap; +use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; +use codex_exec_server::ExecServerError; use codex_exec_server::ExecutorFileSystem; -use codex_protocol::error::CodexErr; -use codex_protocol::error::Result as CodexResult; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -31,13 +32,37 @@ pub(crate) fn default_thread_environment_selections( .collect() } -type SnapshotTask = Shared>; +type TurnEnvironmentResult = Result>; +type TurnEnvironmentResolution = Shared>; + +#[derive(Clone)] +struct SelectedTurnEnvironment { + selection: TurnEnvironmentSelection, + resolution: TurnEnvironmentResolution, +} + +#[derive(Clone)] +pub(crate) struct StartingTurnEnvironment { + pub(crate) selection: TurnEnvironmentSelection, + resolution: TurnEnvironmentResolution, +} + +impl fmt::Debug for StartingTurnEnvironment { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StartingTurnEnvironment") + .field("selection", &self.selection) + .field("resolved", &self.resolution.peek().is_some()) + .finish_non_exhaustive() + } +} pub(crate) struct ThreadEnvironments { environment_manager: Arc, local_shell: Shell, shell_snapshot: ShellSnapshot, - snapshot_task: ArcSwap, + non_blocking_snapshots: bool, + environments: ArcSwap>, } impl ThreadEnvironments { @@ -46,137 +71,142 @@ impl ThreadEnvironments { local_shell: Shell, shell_snapshot: ShellSnapshot, current: TurnEnvironmentSnapshot, + non_blocking_snapshots: bool, ) -> Self { + // Reuse only attached environments from the supplied snapshot; drop starting entries. + let environments = current + .turn_environments + .into_iter() + .map(|environment| { + let selection = environment.selection(); + let resolution: TurnEnvironmentResolution = + futures::future::ready(Ok(environment)).boxed().shared(); + SelectedTurnEnvironment { + selection, + resolution, + } + }) + .collect(); Self { environment_manager, local_shell, shell_snapshot, - snapshot_task: ArcSwap::from_pointee(futures::future::ready(current).boxed().shared()), + non_blocking_snapshots, + environments: ArcSwap::from_pointee(environments), } } pub(crate) fn update_selections(&self, environments: &[TurnEnvironmentSelection]) { - let previous = self - .snapshot_task - .load() - .peek() - .cloned() - .unwrap_or_default(); - let environment_manager = Arc::clone(&self.environment_manager); - let local_shell = self.local_shell.clone(); - let shell_snapshot = self.shell_snapshot.clone(); - let environments = environments.to_vec(); - let (snapshot_task, snapshot) = async move { - Self::resolve_snapshot( - environment_manager, - local_shell, - shell_snapshot, - previous, - environments, - ) - .await - } - .remote_handle(); - self.snapshot_task - .store(Arc::new(snapshot.boxed().shared())); - drop(tokio::spawn(snapshot_task)); - } - - async fn resolve_snapshot( - environment_manager: Arc, - local_shell: Shell, - shell_snapshot: ShellSnapshot, - current: TurnEnvironmentSnapshot, - environments: Vec, - ) -> TurnEnvironmentSnapshot { + let previous = self.environments.load(); let mut seen_environment_ids = HashSet::with_capacity(environments.len()); - let mut turn_environments = Vec::with_capacity(environments.len()); - for selected_environment in &environments { + let mut next = Vec::with_capacity(environments.len()); + for selected_environment in environments { if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) { continue; } - let turn_environment = match current.turn_environments.iter().find(|environment| { - environment.environment_id == selected_environment.environment_id - && environment.cwd_uri() == &selected_environment.cwd - }) { - Some(environment) => environment.clone(), - None => match Self::resolve_selection( - &environment_manager, - &local_shell, - &shell_snapshot, - selected_environment, - ) - .await - { - Ok(environment) => environment, - Err(err) => { - tracing::warn!( - "skipping unresolved turn environment `{}`: {err}", - selected_environment.environment_id - ); - continue; - } - }, + if let Some(environment) = previous + .iter() + .find(|environment| environment.selection == *selected_environment) + && !matches!(environment.resolution.clone().now_or_never(), Some(Err(_))) + { + next.push(environment.clone()); + continue; + } + + let environment_id = &selected_environment.environment_id; + let Some(environment) = self.environment_manager.get_environment(environment_id) else { + tracing::warn!("skipping unknown turn environment `{environment_id}`"); + continue; }; - turn_environments.push(turn_environment); + let (resolution_task, resolution) = Self::resolve_environment( + selected_environment.clone(), + environment, + self.local_shell.clone(), + self.shell_snapshot.clone(), + ) + .remote_handle(); + drop(tokio::spawn(resolution_task)); + let resolution = resolution.boxed().shared(); + next.push(SelectedTurnEnvironment { + selection: selected_environment.clone(), + resolution, + }); } - TurnEnvironmentSnapshot { turn_environments } + self.environments.store(Arc::new(next)); } - async fn resolve_selection( - environment_manager: &EnvironmentManager, - local_shell: &Shell, - shell_snapshot: &ShellSnapshot, - selected_environment: &TurnEnvironmentSelection, - ) -> CodexResult { - let environment_id = selected_environment.environment_id.clone(); - let environment = environment_manager - .get_environment(&environment_id) - .ok_or_else(|| { - CodexErr::InvalidRequest(format!("unknown turn environment id `{environment_id}`")) - })?; - let shell = if environment.is_remote() { - match environment.info().await { - Ok(info) => match Shell::from_environment_shell_info(info.shell) { - Ok(shell) => Some(shell), + fn resolve_environment( + selection: TurnEnvironmentSelection, + environment: Arc, + local_shell: Shell, + shell_snapshot: ShellSnapshot, + ) -> BoxFuture<'static, TurnEnvironmentResult> { + async move { + let environment_id = &selection.environment_id; + if let Err(err) = environment.wait_until_ready().await { + tracing::warn!("turn environment `{environment_id}` failed to start: {err}"); + return Err(Arc::new(err)); + } + let shell = if environment.is_remote() { + match environment.info().await { + Ok(info) => match Shell::from_environment_shell_info(info.shell) { + Ok(shell) => Some(shell), + Err(err) => { + tracing::warn!( + "failed to resolve shell for environment `{environment_id}`: {err}" + ); + None + } + }, Err(err) => { tracing::warn!( - "failed to resolve shell for environment `{environment_id}`: {err}" + "failed to get info for environment `{environment_id}`: {err}" ); None } - }, - Err(err) => { - tracing::warn!("failed to get info for environment `{environment_id}`: {err}"); - None } - } - } else { - Some(local_shell.clone()) - }; - let mut turn_environment = TurnEnvironment::new( - environment_id, - environment, - selected_environment.cwd.to_abs_path().map_err(|err| { - CodexErr::InvalidRequest(format!( - "turn environment cwd `{}` is not valid on this host: {err}", - selected_environment.cwd - )) - })?, - shell, - ); - let task = shell_snapshot - .clone() - .build(turn_environment.clone()) - .boxed() - .shared(); - drop(tokio::spawn(task.clone())); - turn_environment.shell_snapshot = task; - Ok(turn_environment) + } else { + Some(local_shell) + }; + let mut turn_environment = + TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell); + let task = shell_snapshot + .build(turn_environment.clone()) + .boxed() + .shared(); + drop(tokio::spawn(task.clone())); + turn_environment.shell_snapshot = task; + Ok(turn_environment) + } + .boxed() } pub(crate) async fn snapshot(&self) -> TurnEnvironmentSnapshot { - self.snapshot_task.load_full().as_ref().clone().await + let current = self.environments.load_full(); + let mut turn_environments = Vec::with_capacity(current.len()); + let mut starting = Vec::new(); + for environment in current.iter() { + let resolved = if self.non_blocking_snapshots { + environment.resolution.clone().now_or_never() + } else { + Some(environment.resolution.clone().await) + }; + match resolved { + Some(Ok(turn_environment)) => turn_environments.push(turn_environment), + Some(Err(err)) => tracing::debug!( + environment_id = %environment.selection.environment_id, + "skipping failed turn environment: {err}" + ), + None => starting.push(StartingTurnEnvironment { + selection: environment.selection.clone(), + resolution: environment.resolution.clone(), + }), + } + } + TurnEnvironmentSnapshot { + turn_environments, + starting, + } } pub(crate) fn environment_manager(&self) -> Arc { @@ -187,6 +217,7 @@ impl ThreadEnvironments { #[derive(Clone, Debug, Default)] pub(crate) struct TurnEnvironmentSnapshot { pub(crate) turn_environments: Vec, + pub(crate) starting: Vec, } impl TurnEnvironmentSnapshot { @@ -219,6 +250,9 @@ impl TurnEnvironmentSnapshot { } pub(crate) fn single_local_environment(&self) -> Option<&TurnEnvironment> { + if !self.starting.is_empty() { + return None; + } let [environment] = self.turn_environments.as_slice() else { return None; }; @@ -226,13 +260,17 @@ impl TurnEnvironmentSnapshot { (!environment.environment.is_remote()).then_some(environment) } - pub(crate) fn single_local_environment_cwd(&self) -> Option<&AbsolutePathBuf> { - self.single_local_environment().map(TurnEnvironment::cwd) + pub(crate) fn single_local_environment_cwd(&self) -> Option { + // TODO(anp): Migrate local-environment consumers to PathUri so this compatibility + // conversion can be removed. + self.single_local_environment()?.cwd().to_abs_path().ok() } } #[cfg(test)] mod tests { + use std::time::Duration; + use codex_exec_server::Environment; use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::LOCAL_ENVIRONMENT_ID; @@ -240,7 +278,16 @@ mod tests { use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; + use futures::SinkExt; + use futures::StreamExt; use pretty_assertions::assert_eq; + use serde_json::Value; + 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; use super::*; @@ -253,6 +300,7 @@ mod tests { crate::shell::default_user_shell(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, )); turn_environments.update_selections(selections); turn_environments.snapshot().await; @@ -267,6 +315,61 @@ mod tests { .expect("runtime paths") } + async fn read_websocket_json(websocket: &mut WebSocketStream) -> Value { + loop { + match timeout(std::time::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_websocket_json(&mut websocket).await; + assert_eq!(initialize["method"], "initialize"); + websocket + .send(Message::Text( + serde_json::json!({ + "id": initialize["id"], + "result": { "sessionId": "test-session" } + }) + .to_string() + .into(), + )) + .await + .expect("initialize response"); + let initialized = read_websocket_json(&mut websocket).await; + assert_eq!(initialized["method"], "initialized"); + + let info = read_websocket_json(&mut websocket).await; + assert_eq!(info["method"], "environment/info"); + websocket + .send(Message::Text( + serde_json::json!({ + "id": info["id"], + "result": { "shell": { "name": "zsh", "path": "/bin/zsh" } } + }) + .to_string() + .into(), + )) + .await + .expect("environment info response"); + } + #[tokio::test] async fn default_thread_environment_selections_use_manager_default_id() { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); @@ -343,6 +446,7 @@ url = "ws://127.0.0.1:8765" local_shell.clone(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, ); turn_environments.update_selections(&[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), @@ -451,58 +555,176 @@ url = "ws://127.0.0.1:8765" } #[tokio::test] - async fn latest_environment_update_wins_while_previous_resolution_is_pending() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + async fn blocking_snapshot_waits_for_starting_environment() { + let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind websocket listener"); let manager = Arc::new( - EnvironmentManager::create_for_tests_with_local( + EnvironmentManager::create_for_tests( Some(format!( "ws://{}", listener.local_addr().expect("listener address") )), - test_runtime_paths(), + Some(test_runtime_paths()), ) .await, ); - let cwd = AbsolutePathBuf::current_dir().expect("cwd"); - let turn_environments = Arc::new(ThreadEnvironments::new( + let selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + }; + let environments = Arc::new(ThreadEnvironments::new( manager, crate::shell::default_user_shell(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, )); - turn_environments.update_selections(&[TurnEnvironmentSelection { + environments.update_selections(std::slice::from_ref(&selection)); + let snapshot_task = tokio::spawn({ + let environments = Arc::clone(&environments); + async move { environments.snapshot().await } + }); + tokio::task::yield_now().await; + assert!(!snapshot_task.is_finished()); + + let server = tokio::spawn(serve_environment_info(listener)); + let snapshot = timeout(Duration::from_secs(5), snapshot_task) + .await + .expect("snapshot should finish after the environment starts") + .expect("snapshot task"); + + assert!(snapshot.starting.is_empty()); + assert_eq!(snapshot.to_selections(), vec![selection]); + server.await.expect("server task"); + } + + #[tokio::test] + async fn snapshot_keeps_starting_environment_until_it_can_be_attached() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind websocket listener"); + let manager = Arc::new( + EnvironmentManager::create_for_tests_with_local( + Some(format!( + "ws://{}", + listener.local_addr().expect("listener address") + )), + test_runtime_paths(), + ) + .await, + ); + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let cwd = PathUri::from_abs_path(&cwd); + let remote = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_abs_path(&cwd), - }]); - let (_connection, _) = - tokio::time::timeout(std::time::Duration::from_secs(5), listener.accept()) - .await - .expect("remote resolution should connect") - .expect("accept remote resolution connection"); + cwd: cwd.clone(), + }; let local = TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_abs_path(&cwd), + cwd, }; + let turn_environments = ThreadEnvironments::new( + manager, + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ true, + ); + turn_environments.update_selections(&[remote.clone(), local.clone()]); - turn_environments.update_selections(std::slice::from_ref(&local)); - let snapshot = tokio::time::timeout( + let starting = turn_environments.snapshot().await; + assert!(starting.turn_environments.is_empty()); + assert_eq!( + starting + .starting + .iter() + .map(|environment| environment.selection.clone()) + .collect::>(), + vec![remote.clone(), local.clone()] + ); + assert!(starting.to_selections().is_empty()); + assert!(starting.single_local_environment().is_none()); + + let server = tokio::spawn(serve_environment_info(listener)); + timeout( std::time::Duration::from_secs(5), - turn_environments.snapshot(), + starting.starting[0].resolution.clone(), ) .await - .expect("latest environment resolution should complete"); + .expect("environment resolution should finish") + .expect("environment resolution should succeed"); + let attached = turn_environments.snapshot().await; + + assert!(attached.starting.is_empty()); + assert_eq!( + attached + .turn_environments + .iter() + .map(TurnEnvironment::selection) + .collect::>(), + vec![remote.clone(), local.clone()] + ); + assert_eq!(attached.to_selections(), vec![remote, local]); + server.await.expect("server task"); + } + + #[tokio::test] + async fn failed_resolution_is_replaced_from_the_environment_manager() { + let manager = Arc::new( + EnvironmentManager::create_for_tests( + Some("http://example.com".to_string()), + Some(test_runtime_paths()), + ) + .await, + ); + let selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + }; + let environments = ThreadEnvironments::new( + Arc::clone(&manager), + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ true, + ); + environments.update_selections(std::slice::from_ref(&selection)); + let failed_resolution = environments.environments.load()[0].resolution.clone(); + assert!(failed_resolution.clone().await.is_err()); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind replacement listener"); + manager + .upsert_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + format!("ws://{}", listener.local_addr().expect("listener address")), + /*connect_timeout*/ None, + ) + .expect("replacement environment"); + environments.update_selections(std::slice::from_ref(&selection)); - assert_eq!(snapshot.to_selections(), vec![local]); + let replacement = environments.snapshot().await; + let [replacement] = replacement.starting.as_slice() else { + panic!("expected the replacement environment to be starting"); + }; + assert_eq!(replacement.selection, selection); + assert!(!failed_resolution.ptr_eq(&replacement.resolution)); } #[tokio::test] - async fn matching_environment_id_and_cwd_reuse_resolved_environment() { + async fn matching_environment_id_and_cwd_reuse_resolution() { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let first_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind first listener"); let manager = Arc::new( EnvironmentManager::create_for_tests( - Some("ws://127.0.0.1:8765".to_string()), + Some(format!( + "ws://{}", + first_listener.local_addr().expect("first listener address") + )), Some(test_runtime_paths()), ) .await, @@ -511,43 +733,100 @@ url = "ws://127.0.0.1:8765" environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), }; - let initial = - resolve_turn_environments(Arc::clone(&manager), std::slice::from_ref(&selection)).await; + let environments = ThreadEnvironments::new( + Arc::clone(&manager), + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ true, + ); + environments.update_selections(std::slice::from_ref(&selection)); + let initial_snapshot = environments.snapshot().await; + let second_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind second listener"); manager .upsert_environment( REMOTE_ENVIRONMENT_ID.to_string(), - "ws://127.0.0.1:9876".to_string(), + format!( + "ws://{}", + second_listener + .local_addr() + .expect("second listener address") + ), + /*connect_timeout*/ None, ) .expect("replace environment"); - let initial_snapshot = initial.snapshot().await; - initial.update_selections(std::slice::from_ref(&selection)); - let reused_snapshot = initial.snapshot().await; - initial.update_selections(&[TurnEnvironmentSelection { + environments.update_selections(std::slice::from_ref(&selection)); + let reused_snapshot = environments.snapshot().await; + environments.update_selections(&[TurnEnvironmentSelection { cwd: PathUri::from_abs_path(&cwd.join("changed")), ..selection }]); - let changed_snapshot = initial.snapshot().await; + let changed_snapshot = environments.snapshot().await; + + let initial = initial_snapshot + .starting + .first() + .expect("initial environment"); + let reused = reused_snapshot + .starting + .first() + .expect("reused environment"); + let changed = changed_snapshot + .starting + .first() + .expect("changed environment"); + assert!(initial.resolution.ptr_eq(&reused.resolution)); + assert!(!reused.resolution.ptr_eq(&changed.resolution)); + } + + #[tokio::test] + async fn inherited_environment_reuses_parent_handle() { + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&cwd), + }; + let inherited_environment = Arc::new( + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("inherited environment"), + ); + let inherited = TurnEnvironment::new( + selection.environment_id.clone(), + Arc::clone(&inherited_environment), + selection.cwd.clone(), + /*shell*/ None, + ); + let manager = Arc::new(EnvironmentManager::without_environments()); + manager + .upsert_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + "ws://127.0.0.1:9876".to_string(), + /*connect_timeout*/ None, + ) + .expect("replacement environment"); + let environments = ThreadEnvironments::new( + manager, + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot { + turn_environments: vec![inherited], + starting: Vec::new(), + }, + /*non_blocking_snapshots*/ false, + ); + + environments.update_selections(std::slice::from_ref(&selection)); + let snapshot = environments.snapshot().await; assert!(Arc::ptr_eq( - &initial_snapshot - .primary() - .expect("initial environment") - .environment, - &reused_snapshot - .primary() - .expect("reused environment") - .environment, - )); - assert!(!Arc::ptr_eq( - &reused_snapshot + &snapshot .primary() - .expect("reused environment") - .environment, - &changed_snapshot - .primary() - .expect("changed environment") + .expect("inherited environment") .environment, + &inherited_environment, )); } @@ -560,7 +839,7 @@ url = "ws://127.0.0.1:8765" Arc::clone(&local_manager), &[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: cwd_uri, + cwd: cwd_uri.clone(), }], ) .await; @@ -573,9 +852,10 @@ url = "ws://127.0.0.1:8765" turn_environments: vec![TurnEnvironment::new( REMOTE_ENVIRONMENT_ID.to_string(), remote_environment.clone(), - cwd.clone(), + cwd_uri.clone(), /*shell*/ None, )], + starting: Vec::new(), }; let multiple = TurnEnvironmentSnapshot { turn_environments: vec![ @@ -583,13 +863,14 @@ url = "ws://127.0.0.1:8765" TurnEnvironment::new( REMOTE_ENVIRONMENT_ID.to_string(), remote_environment, - cwd.clone(), + cwd_uri, /*shell*/ None, ), ], + starting: Vec::new(), }; - assert_eq!(local.single_local_environment_cwd(), Some(&cwd)); + assert_eq!(local.single_local_environment_cwd(), Some(cwd)); assert_eq!(remote.single_local_environment_cwd(), None); assert_eq!(multiple.single_local_environment_cwd(), None); } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 11e0294c33e6..4c57c5cadc9f 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::MULTI_AGENT_MODE_OPEN_TAG; use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::user_input::UserInput; @@ -29,10 +30,13 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", "", COLLABORATION_MODE_OPEN_TAG, + MULTI_AGENT_MODE_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", + // Keep recognizing token-budget wrappers persisted by older versions. "", + "", ]; pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { @@ -178,7 +182,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { }) .collect(); Some(TurnItem::Reasoning(ReasoningItem { - id: id.clone(), + id: id.clone().unwrap_or_default(), summary_text, raw_content, })) @@ -202,7 +206,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { .. } => Some(TurnItem::ImageGeneration( codex_protocol::items::ImageGenerationItem { - id: id.clone(), + id: id.clone()?, status: status.clone(), revised_prompt: revised_prompt.clone(), result: result.clone(), diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index ad5b4c877d13..f4c3ddd3a644 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -29,7 +29,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(), @@ -61,7 +61,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 +111,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 +144,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 +194,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 +227,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 +235,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 +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(), @@ -252,7 +252,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 +260,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 +275,7 @@ fn skips_user_instructions_and_env() { }, ], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ]; for item in items { @@ -325,7 +325,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 +357,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 +372,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"); @@ -391,7 +391,7 @@ fn parses_agent_message() { #[test] fn parses_reasoning_summary_and_raw_content() { let item = ResponseItem::Reasoning { - id: "reasoning_1".to_string(), + id: Some("reasoning_1".to_string()), summary: vec![ ReasoningItemReasoningSummary::SummaryText { text: "Step 1".to_string(), @@ -404,7 +404,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"); @@ -424,7 +424,7 @@ fn parses_reasoning_summary_and_raw_content() { #[test] fn parses_reasoning_including_raw_content() { let item = ResponseItem::Reasoning { - id: "reasoning_2".to_string(), + id: Some("reasoning_2".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "Summarized step".to_string(), }], @@ -437,7 +437,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 +463,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 +492,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 +521,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 +548,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 9468534d8fd3..647203db2e9f 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,9 +1,9 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::collections::BTreeSet; use std::collections::HashMap; use std::io; +#[cfg(target_os = "windows")] use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; @@ -24,7 +24,6 @@ use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::error::SandboxErr; @@ -42,7 +41,15 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; -use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; +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; +use codex_sandboxing::resolve_windows_restricted_token_filesystem_overrides; +#[cfg(test)] +use codex_sandboxing::unsupported_windows_restricted_token_sandbox_reason; +use codex_sandboxing::windows_sandbox_uses_elevated_backend; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; @@ -89,6 +96,7 @@ pub struct ExecParams { pub capture_policy: ExecCapturePolicy, pub env: HashMap, pub network: Option, + pub network_environment_id: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -96,34 +104,6 @@ pub struct ExecParams { pub arg0: Option, } -/// Resolved filesystem overrides for the Windows sandbox backends. -/// -/// The elevated Windows backend consumes extra deny-read paths plus explicit -/// read and write roots during setup/refresh. The unelevated restricted-token -/// backend only consumes extra deny-write carveouts on top of the legacy -/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the -/// baseline helper roots that the elevated setup path needs to launch the -/// sandboxed command; split policies that opt into platform defaults carry -/// that explicitly with the override. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct WindowsSandboxFilesystemOverrides { - pub(crate) read_roots_override: Option>, - pub(crate) read_roots_include_platform_defaults: bool, - pub(crate) write_roots_override: Option>, - pub(crate) additional_deny_read_paths: Vec, - pub(crate) additional_deny_write_paths: Vec, -} - -fn windows_sandbox_uses_elevated_backend( - sandbox_level: WindowsSandboxLevel, - proxy_enforced: bool, -) -> bool { - // Windows firewall enforcement is tied to the logon-user sandbox identities, so - // proxy-enforced sessions must use that backend even when the configured mode is - // the default restricted-token sandbox. - proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated) -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -149,6 +129,16 @@ fn select_process_exec_tool_sandbox_type( ) } +fn network_proxy_environment_error( + network_environment_id: Option<&str>, + err: impl std::fmt::Display, +) -> CodexErr { + let environment_id = network_environment_id.unwrap_or("default"); + CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{environment_id}`: {err}" + ))) +} + /// Mechanism to terminate an exec invocation before it finishes naturally. #[derive(Clone, Debug)] pub enum ExecExpiration { @@ -343,6 +333,7 @@ pub fn build_exec_request( expiration, capture_policy, network, + network_environment_id, windows_sandbox_level, windows_sandbox_private_desktop, @@ -365,7 +356,11 @@ pub fn build_exec_request( tracing::debug!("Sandbox type: {sandbox_type:?}"); if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, network_environment_id.as_deref()) + .map_err(|err| { + network_proxy_environment_error(network_environment_id.as_deref(), err) + })?; } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -394,6 +389,7 @@ pub fn build_exec_request( permissions: permission_profile, sandbox: sandbox_type, enforce_managed_network, + environment_id: network_environment_id.as_deref(), network: network.as_ref(), sandbox_policy_cwd: &sandbox_policy_cwd_uri, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_deref(), @@ -403,7 +399,7 @@ pub fn build_exec_request( }) .map(|request| { let windows_sandbox_workspace_roots = if windows_sandbox_workspace_roots.is_empty() { - vec![request.sandbox_policy_cwd.clone()] + vec![sandbox_cwd.clone()] } else { windows_sandbox_workspace_roots.to_vec() }; @@ -459,9 +455,21 @@ pub(crate) async fn execute_exec_request( file_system_sandbox_policy: _, network_sandbox_policy, 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. + let cwd = cwd + .to_abs_path() + .map_err(|err| CodexErr::InvalidRequest(format!("invalid exec cwd: {err}")))?; + // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. + let windows_sandbox_policy_cwd = windows_sandbox_policy_cwd + .to_abs_path() + .map_err(|err| CodexErr::InvalidRequest(format!("invalid sandbox cwd: {err}")))?; + let params = ExecParams { command, cwd, @@ -469,6 +477,7 @@ pub(crate) async fn execute_exec_request( capture_policy, env, network: network.clone(), + network_environment_id, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop, @@ -606,6 +615,7 @@ async fn exec_windows_sandbox( cwd, mut env, network, + network_environment_id, expiration, capture_policy, windows_sandbox_level, @@ -613,7 +623,11 @@ async fn exec_windows_sandbox( .. } = params; if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, network_environment_id.as_deref()) + .map_err(|err| { + network_proxy_environment_error(network_environment_id.as_deref(), err) + })?; } // Windows sandbox capture still receives timeout and cancellation separately. @@ -806,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, @@ -954,6 +906,7 @@ async fn exec( cwd, mut env, network, + network_environment_id, arg0, expiration, capture_policy, @@ -967,7 +920,11 @@ async fn exec( justification: _, } = params; if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, network_environment_id.as_deref()) + .map_err(|err| { + network_proxy_environment_error(network_environment_id.as_deref(), err) + })?; } let (program, args) = command.split_first().ok_or_else(|| { @@ -997,352 +954,6 @@ async fn exec( consume_output(child, expiration, capture_policy, stdout_stream).await } -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -fn permission_profile_supports_windows_restricted_token_sandbox( - permission_profile: &PermissionProfile, -) -> bool { - match permission_profile { - PermissionProfile::Managed { file_system, .. } => { - !file_system.to_sandbox_policy().has_full_disk_write_access() - } - PermissionProfile::Disabled | PermissionProfile::External { .. } => false, - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - windows_sandbox_level: WindowsSandboxLevel, -) -> Option { - if windows_sandbox_level == WindowsSandboxLevel::Elevated { - resolve_windows_elevated_filesystem_overrides( - sandbox, - permission_profile, - sandbox_policy_cwd, - windows_sandbox_level == WindowsSandboxLevel::Elevated, - ) - .err() - } else { - resolve_windows_restricted_token_filesystem_overrides( - sandbox, - permission_profile, - sandbox_policy_cwd, - windows_sandbox_level, - ) - .err() - } -} - -pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - windows_sandbox_level: WindowsSandboxLevel, -) -> std::result::Result, String> { - if sandbox != SandboxType::WindowsRestrictedToken - || windows_sandbox_level == WindowsSandboxLevel::Elevated - { - return Ok(None); - } - - let (file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - - let needs_direct_runtime_enforcement = file_system_sandbox_policy - .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); - - if permission_profile_supports_windows_restricted_token_sandbox(permission_profile) - && !needs_direct_runtime_enforcement - { - return Ok(None); - } - - if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { - let permission_profile_name = permission_profile_display_name(permission_profile); - return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", - file_system_sandbox_policy.kind, - )); - } - - // The restricted-token backend can still enforce split write restrictions, - // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs - // participate in read access checks. Read restrictions therefore require the - // elevated backend, even when the filesystem root remains readable. - if !windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd) { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - &file_system_sandbox_policy, - sandbox_policy_cwd, - )?; - if !additional_deny_read_paths.is_empty() { - return Err( - "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let legacy_projection = compatibility_sandbox_policy_for_permission_profile( - permission_profile, - sandbox_policy_cwd.as_path(), - ); - let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); - let split_writable_roots = - file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_root_paths: BTreeSet = legacy_writable_roots - .iter() - .map(|root| normalize_windows_override_path(root.root.as_path())) - .collect::>()?; - let split_root_paths: BTreeSet = split_writable_roots - .iter() - .map(|root| normalize_windows_override_path(root.root.as_path())) - .collect::>()?; - - if legacy_root_paths != split_root_paths { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" - .to_string(), - ); - } - - for writable_root in &split_writable_roots { - for read_only_subpath in &writable_root.read_only_subpaths { - if split_writable_roots.iter().any(|candidate| { - candidate.root.as_path() != writable_root.root.as_path() - && candidate - .root - .as_path() - .starts_with(read_only_subpath.as_path()) - }) { - return Err( - "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" - .to_string(), - ); - } - } - } - - let mut additional_deny_write_paths = BTreeSet::new(); - for split_root in &split_writable_roots { - let split_root_path = normalize_windows_override_path(split_root.root.as_path())?; - let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { - normalize_windows_override_path(candidate.root.as_path()) - .is_ok_and(|candidate_path| candidate_path == split_root_path) - }) else { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" - .to_string(), - ); - }; - - for read_only_subpath in &split_root.read_only_subpaths { - if !legacy_root - .read_only_subpaths - .iter() - .any(|candidate| candidate == read_only_subpath) - { - additional_deny_write_paths.insert(normalize_windows_override_path( - read_only_subpath.as_path(), - )?); - } - } - } - - if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { - return Ok(None); - } - - Ok(Some(WindowsSandboxFilesystemOverrides { - read_roots_override: None, - read_roots_include_platform_defaults: false, - write_roots_override: None, - additional_deny_read_paths, - additional_deny_write_paths: additional_deny_write_paths - .into_iter() - .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) - .collect::>()?, - })) -} - -fn normalize_windows_override_path(path: &Path) -> std::result::Result { - AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) - .map(AbsolutePathBuf::into_path_buf) - .map_err(|err| err.to_string()) -} - -fn windows_policy_has_root_read_access( - file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, -) -> bool { - let Some(root) = cwd.as_path().ancestors().last() else { - return false; - }; - file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path()) -} - -pub(crate) fn resolve_windows_elevated_filesystem_overrides( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - use_windows_elevated_backend: bool, -) -> std::result::Result, String> { - if sandbox != SandboxType::WindowsRestrictedToken || !use_windows_elevated_backend { - return Ok(None); - } - - let (file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - - if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { - let permission_profile_name = permission_profile_display_name(permission_profile); - return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", - file_system_sandbox_policy.kind, - )); - } - - let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - &file_system_sandbox_policy, - sandbox_policy_cwd, - )?; - - let split_writable_roots = - file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - if has_reopened_writable_descendant(&split_writable_roots) { - return Err( - "windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let needs_direct_runtime_enforcement = file_system_sandbox_policy - .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); - let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); - let legacy_projection = compatibility_sandbox_policy_for_permission_profile( - permission_profile, - sandbox_policy_cwd.as_path(), - ); - let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_root_paths: BTreeSet = legacy_writable_roots - .iter() - .map(|root| normalize_path(root.root.to_path_buf())) - .collect(); - let split_readable_roots: Vec = file_system_sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) - .map(&normalize_path) - .collect(); - let split_root_paths: Vec = split_writable_roots - .iter() - .map(|root| normalize_path(root.root.to_path_buf())) - .collect(); - let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); - - // `has_full_disk_read_access()` is intentionally false when deny-read - // entries exist. For Windows setup overrides, the important question is - // whether the baseline still reads from the filesystem root and only needs - // additional deny ACLs layered on top. - let split_has_root_read_access = - windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd); - let read_roots_override = if split_has_root_read_access { - None - } else { - Some(split_readable_roots) - }; - - let write_roots_override = if split_root_path_set == legacy_root_paths { - None - } else { - Some(split_root_paths) - }; - - let additional_deny_write_paths = if needs_direct_runtime_enforcement { - let mut deny_paths = BTreeSet::new(); - for writable_root in &split_writable_roots { - let writable_root_path = normalize_path(writable_root.root.to_path_buf()); - let legacy_root = legacy_writable_roots.iter().find(|candidate| { - normalize_path(candidate.root.to_path_buf()) == writable_root_path - }); - for read_only_subpath in &writable_root.read_only_subpaths { - let read_only_subpath_suffix = read_only_subpath - .as_path() - .strip_prefix(writable_root.root.as_path()) - .ok(); - let already_denied_by_legacy = legacy_root.is_some_and(|legacy_root| { - legacy_root.read_only_subpaths.iter().any(|candidate| { - candidate - .as_path() - .strip_prefix(legacy_root.root.as_path()) - .ok() - == read_only_subpath_suffix - }) - }); - if !already_denied_by_legacy { - deny_paths.insert(normalize_path(read_only_subpath.to_path_buf())); - } - } - } - deny_paths - .into_iter() - .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) - .collect::>()? - } else { - Vec::new() - }; - - if read_roots_override.is_none() - && write_roots_override.is_none() - && additional_deny_read_paths.is_empty() - && additional_deny_write_paths.is_empty() - { - return Ok(None); - } - - Ok(Some(WindowsSandboxFilesystemOverrides { - read_roots_include_platform_defaults: read_roots_override.is_some() - && file_system_sandbox_policy.include_platform_defaults(), - read_roots_override, - write_roots_override, - additional_deny_read_paths, - additional_deny_write_paths, - })) -} - -fn permission_profile_display_name(permission_profile: &PermissionProfile) -> &'static str { - match permission_profile { - PermissionProfile::Managed { .. } => "Managed", - PermissionProfile::Disabled => "Disabled", - PermissionProfile::External { .. } => "External", - } -} - -fn has_reopened_writable_descendant( - writable_roots: &[codex_protocol::protocol::WritableRoot], -) -> bool { - writable_roots.iter().any(|writable_root| { - writable_root - .read_only_subpaths - .iter() - .any(|read_only_subpath| { - writable_roots.iter().any(|candidate| { - candidate.root.as_path() != writable_root.root.as_path() - && candidate - .root - .as_path() - .starts_with(read_only_subpath.as_path()) - }) - }) - }) -} - /// Consumes the output of a child process according to the configured capture /// policy. async fn consume_output( diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 84dfaba5d7fa..1a4477599de3 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -273,6 +273,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { capture_policy: ExecCapturePolicy::FullBuffer, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -309,6 +310,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p capture_policy: ExecCapturePolicy::FullBuffer, env: std::env::vars().collect(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -356,6 +358,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result capture_policy: ExecCapturePolicy::FullBuffer, env: std::env::vars().collect(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -998,6 +1001,7 @@ fn build_exec_request_preserves_windows_workspace_roots() -> Result<()> { capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1052,6 +1056,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1107,6 +1112,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1190,6 +1196,7 @@ while :; do sleep 1; done"# capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index c40a03bf5ab2..1031464ca26c 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; -use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::ResponseItem; +use codex_protocol::models::plaintext_agent_message_content; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::user_input::UserInput; @@ -455,20 +455,10 @@ pub(crate) fn collect_guardian_transcript_entries( } ResponseItem::AgentMessage { author, content, .. - } => { - let text = content - .iter() - .filter_map(|content| match content { - AgentMessageInputContent::InputText { text } => Some(text.as_str()), - AgentMessageInputContent::EncryptedContent { .. } => None, - }) - .collect::>() - .join("\n"); - (!text.trim().is_empty()).then(|| GuardianTranscriptEntry { - kind: GuardianTranscriptEntryKind::Assistant, - text: format!("Agent message from {author}:\n{text}"), - }) - } + } => plaintext_agent_message_content(content).map(|text| GuardianTranscriptEntry { + kind: GuardianTranscriptEntryKind::Assistant, + text: format!("Agent message from {author}:\n{text}"), + }), ResponseItem::LocalShellCall { action, .. } => serialized_entry( GuardianTranscriptEntryKind::Tool("tool shell call".to_string()), serde_json::to_string(action).ok(), diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 92223083d8d7..4756cc1fc404 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 9d96a2d60bb8..de56de41a658 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, @@ -750,11 +793,13 @@ async fn run_review_on_session( .unwrap_or_default(); let guardian_permission_profile = PermissionProfile::read_only(); let parent_turn_environments = params.parent_turn.environments.to_selections(); + // TODO(anp): Migrate guardian review thread settings to a PathUri fallback cwd so foreign + // parent environments do not fall back to the host-native config cwd. let parent_turn_legacy_fallback_cwd = params .parent_turn .environments .primary() - .map(|environment| environment.cwd().clone()) + .and_then(|environment| environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| params.parent_turn.config.cwd.clone()); let submit_result = run_before_review_deadline( @@ -1544,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 76532e3bec5b..23312bc4518b 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -108,7 +108,7 @@ impl codex_extension_api::ThreadLifecycleContributor for GuardianMemoryC } impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, thread_store: &'a codex_extension_api::ExtensionData, @@ -299,7 +299,7 @@ async fn seed_guardian_parent_history(session: &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 +541,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 +664,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 +673,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 +690,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 +699,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 +747,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 +756,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 +784,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 +793,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 +818,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,14 +826,15 @@ 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, call_id: "call-1".to_string(), output: codex_protocol::models::FunctionCallOutputPayload::from_text( "repo is public".to_string(), ), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -841,7 +843,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, }, ]; @@ -1680,7 +1682,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() "---\nname: {GUARDIAN_SKILL_NAME}\ndescription: Guardian skill injection probe.\n---\n\n{GUARDIAN_SKILL_BODY_PROBE}\n" ), )?; - session.services.skills_manager.clear_cache(); + session.services.skills_service.clear_cache(); turn.config = Arc::clone(&config); turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone()); let session = Arc::new(session); @@ -1698,7 +1700,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 +1932,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 +1941,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 +1979,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 +1988,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, }, ], ) @@ -2731,7 +2733,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(), @@ -2739,7 +2741,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; @@ -2798,7 +2800,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(), @@ -2806,7 +2808,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/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 325fd17c9f39..96ae6c9908a3 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -25,6 +25,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; @@ -490,7 +491,7 @@ pub(crate) async fn run_legacy_after_agent_hook( }; let event = EventMsg::Error(codex_protocol::protocol::ErrorEvent { message, - codex_error_info: None, + codex_error_info: Some(CodexErrorInfo::Other), }); sess.send_event(turn_context, event).await; true diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 01d512c75862..7bc4b8e103a1 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -49,7 +49,7 @@ fn preparation_preserves_small_image_bytes_and_non_data_urls() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -86,7 +86,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); @@ -106,6 +106,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { let (valid_image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32); let expected_valid_image_url = valid_image_url.clone(); let mut items = vec![ResponseItem::CustomToolCallOutput { + id: None, call_id: "call-1".to_string(), name: None, output: FunctionCallOutputPayload { @@ -132,7 +133,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); @@ -140,6 +141,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { assert_eq!( items, vec![ResponseItem::CustomToolCallOutput { + id: None, call_id: "call-1".to_string(), name: None, output: FunctionCallOutputPayload { @@ -163,7 +165,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 2bc8bfabdcd1..12cedea4713b 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -1,5 +1,6 @@ //! Root of the `codex-core` library. +#![recursion_limit = "256"] // Prevent accidental direct writes to stdout/stderr in library code. All // user-visible output must go through the appropriate abstraction (e.g., // the TUI or the tracing stack). @@ -38,6 +39,7 @@ pub mod config; pub mod connectors; pub mod context; mod context_manager; +mod current_time; mod environment_selection; pub mod exec; pub mod exec_env; @@ -90,16 +92,14 @@ mod session_prefix; mod session_startup_prewarm; pub mod skills; pub(crate) use skills::SkillInjections; -pub(crate) use skills::SkillLoadOutcome; pub(crate) use skills::SkillMetadata; -pub(crate) use skills::SkillsManager; +pub(crate) use skills::SkillsService; pub(crate) use skills::build_available_skills; pub(crate) use skills::build_skill_injections; pub(crate) use skills::build_skill_name_counts; pub(crate) use skills::collect_explicit_skill_mentions; pub(crate) use skills::default_skill_metadata_budget; pub(crate) use skills::injection; -pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; @@ -136,6 +136,7 @@ pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; pub use agents_md::LoadedAgentsMd; mod rollout; +mod rollout_budget; pub(crate) mod safety; mod session_rollout_init_error; pub mod shell; @@ -190,6 +191,8 @@ pub use client_common::ResponseEvent; pub use client_common::ResponseStream; pub use codex_prompts::REVIEW_PROMPT; pub use compact::content_items_to_text; +pub use current_time::TimeFuture; +pub use current_time::TimeProvider; pub use event_mapping::parse_turn_item; pub use exec_policy::ExecPolicyError; pub use exec_policy::check_execpolicy_for_warnings; diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index ae44515c6274..4dee0da717a4 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -3,7 +3,8 @@ //! Strategy: //! - Inspect `_meta["openai/fileParams"]` to discover which tool arguments are //! file inputs. -//! - At tool execution time, upload those local files to OpenAI file storage +//! - At tool execution time, read those files from the primary environment, +//! upload them to OpenAI file storage, //! and rewrite only the declared arguments into the provided-file payload //! shape expected by the downstream Apps tool. //! @@ -12,8 +13,10 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use codex_api::upload_local_file; +use codex_api::OPENAI_FILE_UPLOAD_LIMIT_BYTES; +use codex_api::upload_openai_file; use codex_login::CodexAuth; +use codex_utils_path_uri::PathUri; use serde_json::Value as JsonValue; pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files( @@ -62,13 +65,13 @@ async fn rewrite_argument_value_for_openai_files( value: &JsonValue, ) -> Result, String> { match value { - JsonValue::String(path_or_file_ref) => { - let rewritten = build_uploaded_local_argument_value( + JsonValue::String(file_path) => { + let rewritten = build_uploaded_argument_value( turn_context, auth, field_name, /*index*/ None, - path_or_file_ref, + file_path, ) .await?; Ok(Some(rewritten)) @@ -76,15 +79,15 @@ async fn rewrite_argument_value_for_openai_files( JsonValue::Array(values) => { let mut rewritten_values = Vec::with_capacity(values.len()); for (index, item) in values.iter().enumerate() { - let Some(path_or_file_ref) = item.as_str() else { + let Some(file_path) = item.as_str() else { return Ok(None); }; - let rewritten = build_uploaded_local_argument_value( + let rewritten = build_uploaded_argument_value( turn_context, auth, field_name, Some(index), - path_or_file_ref, + file_path, ) .await?; rewritten_values.push(rewritten); @@ -95,38 +98,76 @@ async fn rewrite_argument_value_for_openai_files( } } -async fn build_uploaded_local_argument_value( +async fn build_uploaded_argument_value( turn_context: &TurnContext, auth: Option<&CodexAuth>, field_name: &str, index: Option, file_path: &str, ) -> Result { - #[allow(deprecated)] - let resolved_path = turn_context.resolve_path(Some(file_path.to_string())); + let contextualize_error = |error: String| match index { + Some(index) => { + format!("failed to upload `{file_path}` for `{field_name}[{index}]`: {error}") + } + None => format!("failed to upload `{file_path}` for `{field_name}`: {error}"), + }; let Some(auth) = auth else { - return Err( - "ChatGPT auth is required to upload local files for Codex Apps tools".to_string(), - ); + return Err("ChatGPT auth is required to upload files for Codex Apps tools".to_string()); }; if !auth.uses_codex_backend() { - return Err( - "ChatGPT auth is required to upload local files for Codex Apps tools".to_string(), - ); + return Err("ChatGPT auth is required to upload files for Codex Apps tools".to_string()); + } + let Some(turn_environment) = turn_context.environments.primary() else { + return Err(contextualize_error( + "no primary turn environment is available".to_string(), + )); + }; + // TODO(anp): Resolve app tool file arguments using the selected environment's native path + // convention so uploads can read relative paths from foreign environments. + let native_environment_cwd = turn_environment + .cwd() + .to_abs_path() + .map_err(|error| contextualize_error(error.to_string()))?; + let resolved_path = native_environment_cwd.join(file_path); + let path_uri = PathUri::from_abs_path(&resolved_path); + let fs = turn_environment.environment.get_filesystem(); + let metadata = fs + .get_metadata(&path_uri, /*sandbox*/ None) + .await + .map_err(|error| contextualize_error(error.to_string()))?; + if !metadata.is_file { + return Err(contextualize_error(format!( + "path `{}` is not a file", + resolved_path.display() + ))); + } + if metadata.size > OPENAI_FILE_UPLOAD_LIMIT_BYTES { + return Err(contextualize_error(format!( + "file `{}` is too large: {} bytes exceeds the limit of {} bytes", + resolved_path.display(), + metadata.size, + OPENAI_FILE_UPLOAD_LIMIT_BYTES, + ))); } + let contents = fs + .read_file_stream(&path_uri, /*sandbox*/ None) + .await + .map_err(|error| contextualize_error(error.to_string()))?; + let file_name = resolved_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("file") + .to_string(); let upload_auth = codex_model_provider::auth_provider_from_auth(auth); - let uploaded = upload_local_file( + let uploaded = upload_openai_file( turn_context.config.chatgpt_base_url.trim_end_matches('/'), upload_auth.as_ref(), - &resolved_path, + file_name, + metadata.size, + contents, ) .await - .map_err(|error| match index { - Some(index) => { - format!("failed to upload `{file_path}` for `{field_name}[{index}]`: {error}") - } - None => format!("failed to upload `{file_path}` for `{field_name}`: {error}"), - })?; + .map_err(|error| contextualize_error(error.to_string()))?; Ok(serde_json::json!({ "download_url": uploaded.download_url, "file_id": uploaded.file_id, @@ -141,11 +182,30 @@ async fn build_uploaded_local_argument_value( mod tests { use super::*; use crate::session::tests::make_session_and_context; + use crate::session::turn_context::TurnEnvironment; use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; + use std::path::Path; use std::sync::Arc; use tempfile::tempdir; + fn set_primary_environment_cwd(turn_context: &mut TurnContext, cwd: &Path) { + let cwd = AbsolutePathBuf::try_from(cwd).expect("absolute path"); + turn_context.permission_profile = codex_protocol::models::PermissionProfile::Disabled; + let primary = turn_context + .environments + .turn_environments + .first_mut() + .expect("primary environment"); + *primary = TurnEnvironment::new( + primary.environment_id.clone(), + Arc::clone(&primary.environment), + PathUri::from_abs_path(&cwd), + primary.shell.clone(), + ); + } + #[tokio::test] async fn openai_file_argument_rewrite_requires_declared_file_params() { let (session, turn_context) = make_session_and_context().await; @@ -166,7 +226,7 @@ mod tests { } #[tokio::test] - async fn build_uploaded_local_argument_value_uploads_local_file_path() { + async fn build_uploaded_argument_value_uploads_environment_file() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -217,16 +277,13 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - #[allow(deprecated)] - { - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); - } + set_primary_environment_cwd(&mut turn_context, dir.path()); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); turn_context.config = Arc::new(config); - let rewritten = build_uploaded_local_argument_value( + let rewritten = build_uploaded_argument_value( &turn_context, Some(&auth), "file", @@ -249,6 +306,31 @@ mod tests { ); } + #[tokio::test] + async fn build_uploaded_argument_value_rejects_oversized_file_before_reading() { + let (_, mut turn_context) = make_session_and_context().await; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let dir = tempdir().expect("temp dir"); + let file_path = dir.path().join("oversized.bin"); + let file = std::fs::File::create(&file_path).expect("create sparse file"); + file.set_len(OPENAI_FILE_UPLOAD_LIMIT_BYTES + 1) + .expect("size sparse file"); + set_primary_environment_cwd(&mut turn_context, dir.path()); + + let error = build_uploaded_argument_value( + &turn_context, + Some(&auth), + "file", + /*index*/ None, + "oversized.bin", + ) + .await + .expect_err("oversized file should be rejected"); + + assert!(error.contains("is too large")); + assert!(error.contains(&(OPENAI_FILE_UPLOAD_LIMIT_BYTES + 1).to_string())); + } + #[tokio::test] async fn rewrite_argument_value_for_openai_files_rewrites_scalar_path() { use wiremock::Mock; @@ -301,10 +383,7 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - #[allow(deprecated)] - { - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); - } + set_primary_environment_cwd(&mut turn_context, dir.path()); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -418,10 +497,7 @@ mod tests { tokio::fs::write(dir.path().join("two.csv"), b"two") .await .expect("write second local file"); - #[allow(deprecated)] - { - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); - } + set_primary_environment_cwd(&mut turn_context, dir.path()); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 708d26654686..56d6e678a051 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -81,6 +81,7 @@ use codex_rollout::state_db; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; +use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use rmcp::model::ToolAnnotations; use serde::Deserialize; @@ -142,14 +143,7 @@ pub(crate) async fn handle_mcp_tool_call( let metadata = lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await; - let item_metadata = McpToolCallItemMetadata { - mcp_app_resource_uri: metadata - .as_ref() - .and_then(|metadata| metadata.mcp_app_resource_uri.clone()), - plugin_id: metadata - .as_ref() - .and_then(|metadata| metadata.plugin_id.clone()), - }; + let item_metadata = McpToolCallItemMetadata::from_tool_metadata(&server, metadata.as_ref()); let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME { let annotations = metadata .as_ref() @@ -311,12 +305,32 @@ pub(crate) struct HandledMcpToolCall { pub(crate) tool_input: JsonValue, } -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] struct McpToolCallItemMetadata { + connector_id: Option, + link_id: Option, mcp_app_resource_uri: Option, plugin_id: Option, } +impl McpToolCallItemMetadata { + fn from_tool_metadata(server: &str, metadata: Option<&McpToolApprovalMetadata>) -> Self { + let trusted_mcp_app_metadata = if server == CODEX_APPS_MCP_SERVER_NAME { + metadata + } else { + None + }; + Self { + connector_id: trusted_mcp_app_metadata + .and_then(|metadata| metadata.connector_id.clone()), + link_id: trusted_mcp_app_metadata.and_then(|metadata| metadata.link_id.clone()), + mcp_app_resource_uri: metadata + .and_then(|metadata| metadata.mcp_app_resource_uri.clone()), + plugin_id: metadata.and_then(|metadata| metadata.plugin_id.clone()), + } + } +} + async fn handle_approved_mcp_tool_call( sess: &Session, turn_context: &TurnContext, @@ -626,7 +640,11 @@ async fn maybe_request_codex_apps_auth_elicitation( return result; } - if !turn_context.features.enabled(Feature::AuthElicitation) { + if !turn_context + .config + .features + .enabled(Feature::AuthElicitation) + { return result; } @@ -709,10 +727,8 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( server: &str, mut meta: Option, ) -> anyhow::Result> { - let supports_sandbox_state_meta = sess - .services - .mcp_connection_manager - .load_full() + let mcp_connection_manager = sess.services.mcp_connection_manager.load_full(); + let supports_sandbox_state_meta = mcp_connection_manager .server_supports_sandbox_state_meta_capability(server) .await .unwrap_or(false); @@ -720,13 +736,18 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( return Ok(meta); } + let server_environment_id = mcp_connection_manager + .server_environment_id(server) + .unwrap_or(codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID); + let Some(sandbox_cwd) = sandbox_cwd_for_mcp_server(turn_context, server_environment_id) else { + return Ok(meta); + }; + let permission_profile = turn_context.permission_profile(); let sandbox_state = serde_json::to_value(SandboxState { - permission_profile: Some(turn_context.permission_profile()), - sandbox_policy: turn_context.sandbox_policy(), - codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), - #[allow(deprecated)] - sandbox_cwd: turn_context.cwd.to_path_buf(), - use_legacy_landlock: turn_context.features.use_legacy_landlock(), + permission_profile: Some(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(), })?; match meta.as_mut() { @@ -750,6 +771,24 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( Ok(meta) } +fn sandbox_cwd_for_mcp_server(turn_context: &TurnContext, environment_id: &str) -> Option { + if let Some(environment) = turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id) + { + return Some(environment.cwd().clone()); + } + + if environment_id == codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID { + #[allow(deprecated)] + return Some(PathUri::from_abs_path(&turn_context.cwd)); + } + + None +} + async fn maybe_mark_thread_memory_mode_polluted( sess: &Session, turn_context: &TurnContext, @@ -866,7 +905,9 @@ async fn notify_mcp_tool_call_started( server, tool, arguments: arguments.unwrap_or(JsonValue::Null), + connector_id: item_metadata.connector_id, mcp_app_resource_uri: item_metadata.mcp_app_resource_uri, + link_id: item_metadata.link_id, plugin_id: item_metadata.plugin_id, status: McpToolCallStatus::InProgress, result: None, @@ -906,7 +947,9 @@ async fn notify_mcp_tool_call_completed( server, tool, arguments: arguments.unwrap_or(JsonValue::Null), + connector_id: item_metadata.connector_id, mcp_app_resource_uri: item_metadata.mcp_app_resource_uri, + link_id: item_metadata.link_id, plugin_id: item_metadata.plugin_id, status, result, @@ -972,6 +1015,7 @@ enum McpToolApprovalDecision { pub(crate) struct McpToolApprovalMetadata { annotations: Option, connector_id: Option, + link_id: Option, connector_name: Option, connector_description: Option, plugin_id: Option, @@ -984,6 +1028,7 @@ pub(crate) struct McpToolApprovalMetadata { const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; +const MCP_TOOL_LINK_ID_META_KEY: &str = "link_id"; const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; @@ -1467,6 +1512,13 @@ pub(crate) async fn lookup_mcp_tool_metadata( Some(McpToolApprovalMetadata { annotations: tool_info.tool.annotations, connector_id: tool_info.connector_id, + link_id: tool_info + .tool + .meta + .as_ref() + .and_then(|meta| meta.get(MCP_TOOL_LINK_ID_META_KEY)) + .and_then(serde_json::Value::as_str) + .map(str::to_string), connector_name: tool_info.connector_name, connector_description, plugin_id, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 9a8a9174a12c..eb500b0f6cd7 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -3,6 +3,7 @@ use crate::config::ConfigBuilder; use crate::config::ManagedFeatures; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; +use crate::session::turn_context::TurnEnvironment; use crate::state::ActiveTurn; use crate::test_support::models_manager_with_provider; use crate::tools::hook_names::HookToolName; @@ -31,6 +32,7 @@ use codex_rollout_trace::ToolDispatchInvocation; use codex_rollout_trace::ToolDispatchPayload; use codex_rollout_trace::ToolDispatchRequester; use codex_rollout_trace::replay_bundle; +use codex_utils_path_uri::PathUri; use core_test_support::hooks::trusted_config_layer_stack; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -76,6 +78,7 @@ fn approval_metadata( McpToolApprovalMetadata { annotations: None, connector_id: connector_id.map(str::to_string), + link_id: None, connector_name: connector_name.map(str::to_string), connector_description: connector_description.map(str::to_string), plugin_id: None, @@ -1091,6 +1094,39 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { ); } +#[tokio::test] +async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Result<()> { + let (_, mut turn_context) = make_session_and_context().await; + let secondary_cwd = PathUri::parse("file:///C:/remote/project")?; + let environment = turn_context.environments.turn_environments[0] + .environment + .clone(); + turn_context + .environments + .turn_environments + .push(TurnEnvironment::new( + "remote".to_string(), + environment, + secondary_cwd.clone(), + /*shell*/ None, + )); + + let sandbox_cwd = sandbox_cwd_for_mcp_server(&turn_context, "remote"); + + assert_eq!(sandbox_cwd, Some(secondary_cwd)); + Ok(()) +} + +#[tokio::test] +async fn mcp_sandbox_cwd_is_none_for_unselected_server_environment() -> anyhow::Result<()> { + let (_, turn_context) = make_session_and_context().await; + + let sandbox_cwd = sandbox_cwd_for_mcp_server(&turn_context, "remote"); + + assert_eq!(sandbox_cwd, None); + Ok(()) +} + #[tokio::test] async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { let (_, turn_context) = make_session_and_context().await; @@ -1114,8 +1150,39 @@ async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { ); } +#[test] +fn mcp_tool_call_item_metadata_only_trusts_codex_apps_identity() { + let mut metadata = approval_metadata( + Some("asdk_app_0123456789abcdef0123456789abcdef"), + /*connector_name*/ None, + /*connector_description*/ None, + /*tool_title*/ None, + /*tool_description*/ None, + ); + metadata.link_id = Some("link_fedcba9876543210fedcba9876543210".to_string()); + + assert_eq!( + McpToolCallItemMetadata::from_tool_metadata(CODEX_APPS_MCP_SERVER_NAME, Some(&metadata),), + McpToolCallItemMetadata { + connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()), + link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()), + mcp_app_resource_uri: None, + plugin_id: None, + } + ); + assert_eq!( + McpToolCallItemMetadata::from_tool_metadata("custom_server", Some(&metadata)), + McpToolCallItemMetadata { + connector_id: None, + link_id: None, + mcp_app_resource_uri: None, + plugin_id: None, + } + ); +} + #[tokio::test] -async fn mcp_tool_call_item_includes_plugin_id() { +async fn mcp_tool_call_item_includes_app_identity() { let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; notify_mcp_tool_call_started( @@ -1123,11 +1190,13 @@ async fn mcp_tool_call_item_includes_plugin_id() { &turn_context, "call-plugin", McpInvocation { - server: "sample".to_string(), + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), tool: "echo".to_string(), arguments: None, }, McpToolCallItemMetadata { + connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()), + link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()), mcp_app_resource_uri: None, plugin_id: Some("sample@test".to_string()), }, @@ -1145,6 +1214,14 @@ async fn mcp_tool_call_item_includes_plugin_id() { panic!("expected MCP tool call item"); }; + assert_eq!( + item.connector_id.as_deref(), + Some("asdk_app_0123456789abcdef0123456789abcdef") + ); + assert_eq!( + item.link_id.as_deref(), + Some("link_fedcba9876543210fedcba9876543210") + ); assert_eq!(item.plugin_id.as_deref(), Some("sample@test")); } @@ -1158,6 +1235,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), + link_id: None, connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), plugin_id: None, @@ -1279,6 +1357,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, @@ -1316,15 +1395,14 @@ async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_resu let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); - Arc::get_mut(&mut turn_context) - .expect("single turn context ref") - .features = ManagedFeatures::from(features); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); let returned = maybe_request_codex_apps_auth_elicitation( &session, - &turn_context, + turn_context, "call_123", CODEX_APPS_MCP_SERVER_NAME, Some(&metadata), @@ -1343,7 +1421,7 @@ async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_resul let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); - turn_context.features = ManagedFeatures::from(features); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); turn_context .approval_policy .set(AskForApproval::Never) @@ -1372,7 +1450,7 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); - turn_context.features = ManagedFeatures::from(features); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); turn_context .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { @@ -1407,9 +1485,10 @@ async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() { *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut features = Features::with_defaults(); features.enable(Feature::AuthElicitation); - Arc::get_mut(&mut turn_context) - .expect("single turn context ref") - .features = ManagedFeatures::from(features); + { + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); + } let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); @@ -1625,6 +1704,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2290,6 +2370,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2364,6 +2445,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2421,6 +2503,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2557,6 +2640,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2644,6 +2728,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2698,6 +2783,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2753,6 +2839,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: Some("calendar".to_string()), + link_id: None, connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), plugin_id: None, @@ -2806,6 +2893,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: Some("calendar".to_string()), + link_id: None, connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), plugin_id: None, diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 4cf8ab9d8f32..57f06317a1d5 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 0da42d8a4e33..307115c50535 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -60,6 +60,7 @@ pub async fn build_prompt_input( state_db.clone(), installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let thread = thread_manager.start_thread(config).await?; @@ -80,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.rs b/codex-rs/core/src/realtime_context.rs index 8c2c5b84883d..857389d54256 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -4,8 +4,8 @@ use crate::session::session::Session; use chrono::Utc; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; -use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::ResponseItem; +use codex_protocol::models::plaintext_agent_message_content; use codex_thread_store::ListThreadsParams; use codex_thread_store::SortDirection; use codex_thread_store::StoredThread; @@ -243,16 +243,10 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option { ResponseItem::AgentMessage { author, content, .. } => { - let text = content - .iter() - .filter_map(|content| match content { - AgentMessageInputContent::InputText { text } => Some(text.as_str()), - AgentMessageInputContent::EncryptedContent { .. } => None, - }) - .collect::>() - .join("\n"); - if text.trim().is_empty() || current_user.is_empty() && current_assistant.is_empty() - { + let Some(text) = plaintext_agent_message_content(content) else { + continue; + }; + if current_user.is_empty() && current_assistant.is_empty() { continue; } current_assistant.push(format!("Agent message from {author}:\n{text}")); diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index efe9c12f87f0..ff59438dd55d 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -49,6 +49,10 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .timestamp_opt(1_709_251_200, 0) .single() .expect("valid timestamp"), + recency_at: Utc + .timestamp_opt(1_709_251_200, 0) + .single() + .expect("valid timestamp"), archived_at: None, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), @@ -76,7 +80,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/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 2b5eb91a232e..667939f1056b 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -30,6 +30,7 @@ use codex_login::read_openai_api_key_from_env; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::MessagePhase; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationSpeechParams; @@ -40,7 +41,6 @@ use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationSdpEvent; @@ -107,8 +107,10 @@ struct RealtimeHandoffState { output_tx: Sender, active_handoff: Arc>>, last_output_text: Arc>>, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, session_kind: RealtimeSessionKind, } @@ -116,6 +118,7 @@ struct RealtimeHandoffState { enum RealtimeOutbound { StandaloneHandoff { text: String }, HandoffUpdate { handoff_id: String, text: String }, + HandoffAppend { handoff_id: String, text: String }, CompletedHandoff { handoff_id: String, text: String }, ConversationItem { text: String }, HandoffCompleteAck { handoff_id: String }, @@ -210,16 +213,20 @@ struct RealtimeInputChannels { impl RealtimeHandoffState { fn new( output_tx: Sender, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, session_kind: RealtimeSessionKind, ) -> Self { Self { output_tx, active_handoff: Arc::new(Mutex::new(None)), last_output_text: Arc::new(Mutex::new(None)), + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, session_kind, } } @@ -238,10 +245,11 @@ struct ConversationState { struct RealtimeStart { api_provider: ApiProvider, - architecture: RealtimeConversationArchitecture, extra_headers: Option, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, realtime_call_api_provider: Option, session_config: RealtimeSessionConfig, model_client: ModelClient, @@ -294,10 +302,11 @@ impl RealtimeConversationManager { async fn start_inner(&self, start: RealtimeStart) -> CodexResult { let RealtimeStart { api_provider, - architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, session_config, model_client, @@ -321,8 +330,10 @@ impl RealtimeConversationManager { let realtime_active = Arc::new(AtomicBool::new(true)); let handoff = RealtimeHandoffState::new( handoff_output_tx, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, session_kind, ); let input_channels = RealtimeInputChannels { @@ -337,7 +348,6 @@ impl RealtimeConversationManager { .create_realtime_call_with_headers( sdp, session_config.clone(), - architecture, extra_headers.unwrap_or_default(), realtime_call_api_provider, ) @@ -479,7 +489,11 @@ impl RealtimeConversationManager { Ok(()) } - pub(crate) async fn handoff_out(&self, output_text: String) -> CodexResult<()> { + pub(crate) async fn handoff_out( + &self, + output_text: String, + phase: Option, + ) -> CodexResult<()> { let handoff = { let guard = self.state.lock().await; let Some(state) = guard.as_ref() else { @@ -490,6 +504,13 @@ impl RealtimeConversationManager { state.handoff.clone() }; + if handoff.client_managed_handoffs { + return Ok(()); + } + let response_handoff_prefix = match phase { + Some(MessagePhase::Commentary) => handoff.codex_response_handoff_prefix.clone(), + Some(MessagePhase::FinalAnswer) | None => None, + }; let active_handoff = handoff.active_handoff.lock().await.clone(); let output = match active_handoff { Some(handoff_id) => { @@ -502,6 +523,16 @@ impl RealtimeConversationManager { handoff.codex_response_item_prefix.as_deref(), ), } + } else if handoff.session_kind == RealtimeSessionKind::V1 + && handoff.codex_response_handoff_prefix.is_some() + { + RealtimeOutbound::HandoffAppend { + handoff_id, + text: realtime_backend_item( + output_text, + response_handoff_prefix.as_deref(), + ), + } } else { RealtimeOutbound::HandoffUpdate { handoff_id, @@ -520,7 +551,13 @@ impl RealtimeConversationManager { ), } } else { - RealtimeOutbound::StandaloneHandoff { text: output_text } + RealtimeOutbound::StandaloneHandoff { + text: if handoff.session_kind == RealtimeSessionKind::V1 { + realtime_backend_item(output_text, response_handoff_prefix.as_deref()) + } else { + output_text + }, + } } } }; @@ -565,6 +602,9 @@ impl RealtimeConversationManager { let Some(handoff) = handoff else { return Ok(()); }; + if handoff.client_managed_handoffs { + return Ok(()); + } match handoff.session_kind { RealtimeSessionKind::V1 => return Ok(()), RealtimeSessionKind::V2 => {} @@ -673,10 +713,11 @@ pub(crate) async fn handle_start( struct PreparedRealtimeConversationStart { api_provider: ApiProvider, - architecture: RealtimeConversationArchitecture, extra_headers: Option, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, realtime_call_api_provider: Option, requested_realtime_session_id: Option, version: RealtimeWsVersion, @@ -684,6 +725,12 @@ struct PreparedRealtimeConversationStart { transport: ConversationStartTransport, } +#[derive(Clone, Copy)] +pub(crate) enum ConfiguredRealtimeVoice { + Use, + Ignore, +} + async fn prepare_realtime_start( sess: &Arc, params: ConversationStartParams, @@ -712,16 +759,21 @@ async fn prepare_realtime_start( } else { None }; - let version = params.version.unwrap_or(config.realtime.version); - // TODO(pbakkum): Remove the realtimeapi/AVAS branch once WebRTC realtime sessions always use AVAS. - let architecture = params.architecture.unwrap_or(config.realtime.architecture); - validate_realtime_architecture( - architecture, - version, - &transport, - config.realtime.session_type, - )?; - let session_config = build_realtime_session_config(sess, ¶ms, version).await?; + let version = params.version.unwrap_or(match &transport { + ConversationStartTransport::Websocket => config.realtime.version, + ConversationStartTransport::Webrtc { .. } => RealtimeWsVersion::V1, + }); + if matches!(transport, ConversationStartTransport::Webrtc { .. }) { + validate_avas_webrtc_start(version, config.realtime.session_type)?; + } + let configured_voice = match (&transport, params.version) { + (ConversationStartTransport::Webrtc { .. }, None) => ConfiguredRealtimeVoice::Ignore, + (ConversationStartTransport::Webrtc { .. } | ConversationStartTransport::Websocket, _) => { + ConfiguredRealtimeVoice::Use + } + }; + let session_config = + build_realtime_session_config(sess, ¶ms, version, configured_voice).await?; let requested_realtime_session_id = session_config.session_id.clone(); let extra_headers = match transport { ConversationStartTransport::Websocket => { @@ -742,10 +794,11 @@ async fn prepare_realtime_start( }; Ok(PreparedRealtimeConversationStart { api_provider, - architecture, extra_headers, + client_managed_handoffs: params.client_managed_handoffs, codex_responses_as_items: params.codex_responses_as_items, codex_response_item_prefix: params.codex_response_item_prefix, + codex_response_handoff_prefix: params.codex_response_handoff_prefix, realtime_call_api_provider, requested_realtime_session_id, version, @@ -754,28 +807,18 @@ async fn prepare_realtime_start( }) } -fn validate_realtime_architecture( - architecture: RealtimeConversationArchitecture, +fn validate_avas_webrtc_start( version: RealtimeWsVersion, - transport: &ConversationStartTransport, session_type: RealtimeWsMode, ) -> CodexResult<()> { - if architecture != RealtimeConversationArchitecture::Avas { - return Ok(()); - } if version != RealtimeWsVersion::V1 { return Err(CodexErr::InvalidRequest( - "AVAS realtime architecture requires realtime v1".to_string(), - )); - } - if !matches!(transport, ConversationStartTransport::Webrtc { .. }) { - return Err(CodexErr::InvalidRequest( - "AVAS realtime architecture requires WebRTC transport".to_string(), + "AVAS realtime calls require realtime v1".to_string(), )); } if session_type != RealtimeWsMode::Conversational { return Err(CodexErr::InvalidRequest( - "AVAS realtime architecture requires conversational realtime".to_string(), + "AVAS realtime calls require conversational realtime".to_string(), )); } Ok(()) @@ -785,6 +828,7 @@ pub(crate) async fn build_realtime_session_config( sess: &Arc, params: &ConversationStartParams, version: RealtimeWsVersion, + configured_voice: ConfiguredRealtimeVoice, ) -> CodexResult { let config = sess.get_config().await; let prompt = prepare_realtime_backend_prompt( @@ -831,9 +875,13 @@ pub(crate) async fn build_realtime_session_config( RealtimeWsMode::Conversational => RealtimeSessionMode::Conversational, RealtimeWsMode::Transcription => RealtimeSessionMode::Transcription, }; + let config_voice = match configured_voice { + ConfiguredRealtimeVoice::Use => config.realtime.voice, + ConfiguredRealtimeVoice::Ignore => None, + }; let voice = params .voice - .or(config.realtime.voice) + .or(config_voice) .unwrap_or_else(|| default_realtime_voice(version)); validate_realtime_voice(version, voice)?; Ok(RealtimeSessionConfig { @@ -912,10 +960,11 @@ async fn handle_start_inner( ) -> CodexResult<()> { let PreparedRealtimeConversationStart { api_provider, - architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, requested_realtime_session_id, version, @@ -929,10 +978,11 @@ async fn handle_start_inner( }; let start = RealtimeStart { api_provider, - architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, session_config, model_client: sess.services.model_client.clone(), @@ -1368,6 +1418,11 @@ async fn handle_handoff_output( .send_conversation_function_call_output(handoff_id, text) .await } + RealtimeOutbound::HandoffAppend { handoff_id, text } => { + writer + .send_conversation_handoff_append(handoff_id, text) + .await + } RealtimeOutbound::ConversationItem { text } => { writer .send_conversation_item_create(text, ConversationTextRole::Developer) @@ -1388,7 +1443,8 @@ async fn handle_handoff_output( .await; } } - RealtimeOutbound::HandoffUpdate { handoff_id, text } => { + RealtimeOutbound::HandoffUpdate { handoff_id, text } + | RealtimeOutbound::HandoffAppend { handoff_id, text } => { let active_handoff = handoff_state.active_handoff.lock().await.clone(); match active_handoff { Some(active_handoff) if active_handoff == handoff_id => {} diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs index 147e0cc1236b..55525774b342 100644 --- a/codex-rs/core/src/realtime_conversation_tests.rs +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -130,8 +130,10 @@ async fn clears_active_handoff_explicitly() { let (tx, _rx) = bounded(1); let state = RealtimeHandoffState::new( tx, + /*client_managed_handoffs*/ false, /*codex_responses_as_items*/ false, /*codex_response_item_prefix*/ None, + /*codex_response_handoff_prefix*/ None, RealtimeSessionKind::V1, ); diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 6e86ed8eb3fa..1852c4bea680 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 new file mode 100644 index 000000000000..334e9e73a585 --- /dev/null +++ b/codex-rs/core/src/rollout_budget.rs @@ -0,0 +1,114 @@ +use crate::config::RolloutBudgetConfig; +use codex_protocol::ThreadId; +use codex_protocol::protocol::TokenUsage; +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::MutexGuard; +use std::sync::OnceLock; + +pub(crate) struct RolloutBudgetReminder { + pub(crate) remaining_tokens: i64, + reminder_index: i64, +} + +/// Shared accounting and reminder state for one root-thread session tree. +#[derive(Default)] +pub(crate) struct RolloutBudget { + state: OnceLock>, +} + +struct RolloutBudgetState { + config: RolloutBudgetConfig, + weighted_tokens_used: f64, + /// Last reminder delivered to each thread, so every thread observes crossed thresholds. + deliveries: HashMap, +} + +struct ThreadBudgetDelivery { + window_id: String, + reminder_index: i64, +} + +impl RolloutBudget { + pub(crate) fn configure(&self, config: RolloutBudgetConfig) { + self.state.get_or_init(|| { + Mutex::new(RolloutBudgetState { + config, + weighted_tokens_used: 0.0, + deliveries: HashMap::new(), + }) + }); + } + + /// Returns true once the configured budget is exhausted, including on later calls. + pub(crate) fn record_usage(&self, usage: &TokenUsage) -> bool { + let Some(mut state) = self.lock() else { + return false; + }; + state.weighted_tokens_used += usage.output_tokens.max(0) as f64 + * state.config.sampling_token_weight + + usage.non_cached_input() as f64 * state.config.prefill_token_weight; + state.weighted_tokens_used >= state.config.limit_tokens as f64 + } + + pub(crate) fn pending_reminder( + &self, + thread_id: ThreadId, + window_id: &str, + ) -> Option { + let state = self.lock()?; + 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, + reminder_index, + }) + } + + pub(crate) fn mark_reminder_delivered( + &self, + thread_id: ThreadId, + window_id: &str, + reminder: RolloutBudgetReminder, + ) { + // Mark delivery only after history insertion; cancellation before then should retry it. + let Some(mut state) = self.lock() else { + return; + }; + state.deliveries.insert( + thread_id, + ThreadBudgetDelivery { + window_id: window_id.to_string(), + reminder_index: reminder.reminder_index, + }, + ); + } + + /// Forces the next sampling request for `thread_id` to restate the current remainder. + pub(crate) fn rearm_reminder(&self, thread_id: ThreadId) { + let Some(mut state) = self.lock() else { + return; + }; + state.deliveries.remove(&thread_id); + } + + fn lock(&self) -> Option> { + self.state.get().map(|state| { + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }) + } +} diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index dbae1c0c6877..916379705b22 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -2,7 +2,6 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; -use crate::util::resolve_path; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; use codex_protocol::config_types::WindowsSandboxLevel; @@ -11,7 +10,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_sandboxing::SandboxType; use codex_sandboxing::get_platform_sandbox; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; const PATCH_REJECTED_OUTSIDE_PROJECT_REASON: &str = "writing outside of the project; rejected by user approval settings"; @@ -35,7 +34,7 @@ pub fn assess_patch_safety( policy: AskForApproval, permission_profile: &PermissionProfile, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, + cwd: &PathUri, windows_sandbox_level: WindowsSandboxLevel, ) -> SafetyCheck { if action.is_empty() { @@ -118,14 +117,17 @@ pub fn assess_patch_safety( fn patch_rejection_reason( permission_profile: &PermissionProfile, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, + cwd: &PathUri, ) -> &'static str { + let has_no_writable_roots = cwd.to_abs_path().is_ok_and(|cwd| { + file_system_sandbox_policy + .get_writable_roots_with_cwd(cwd.as_path()) + .is_empty() + }); match permission_profile { PermissionProfile::Managed { .. } if !file_system_sandbox_policy.has_full_disk_write_access() - && file_system_sandbox_policy - .get_writable_roots_with_cwd(cwd.as_path()) - .is_empty() => + && has_no_writable_roots => { PATCH_REJECTED_READ_ONLY_REASON } @@ -138,8 +140,17 @@ fn patch_rejection_reason( fn is_write_patch_constrained_to_writable_paths( action: &ApplyPatchAction, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, + cwd: &PathUri, ) -> bool { + // A full-disk policy permits every patch target, so no per-path writable-root check can + // further constrain the result. + if file_system_sandbox_policy.has_full_disk_write_access() { + return true; + } + // TODO(anp): Make filesystem sandbox policies operate on PathUri. + let Ok(native_cwd) = cwd.to_abs_path() else { + return false; + }; // Normalize a path by removing `.` and resolving `..` without touching the // filesystem (works even if the file does not exist). fn normalize(path: &Path) -> Option { @@ -159,14 +170,18 @@ fn is_write_patch_constrained_to_writable_paths( // Determine whether `path` is inside **any** writable root. Both `path` // and roots are converted to absolute, normalized forms before the // prefix check. - let is_path_writable = |p: &Path| { - let abs = resolve_path(cwd, &p.to_path_buf()); + let is_path_writable = |path: &PathUri| { + // TODO(anp): Make sandbox policy path checks accept PathUri without host projection. + let Ok(path) = path.to_abs_path() else { + return false; + }; + let abs = path.into_path_buf(); let abs = match normalize(&abs) { Some(v) => v, None => return false, }; - file_system_sandbox_policy.can_write_path_with_cwd(&abs, cwd) + file_system_sandbox_policy.can_write_path_with_cwd(&abs, &native_cwd) }; for (path, change) in action.changes() { diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index ca15d0fe4200..6f7efe893bef 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -7,6 +7,7 @@ use codex_protocol::protocol::FileSystemSandboxEntry; use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::GranularApprovalConfig; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use core_test_support::PathExt; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -17,11 +18,13 @@ fn test_writable_roots_constraint() { // the real current working directory. let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let parent = cwd.parent().unwrap(); // Helper to build a single‑entry patch that adds a file at `p`. - let make_add_change = - |p: AbsolutePathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string()); + let make_add_change = |p: AbsolutePathBuf| { + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&p), "".to_string()) + }; let add_inside = make_add_change(cwd.join("inner.txt")); let add_outside = make_add_change(parent.join("outside.txt")); @@ -37,13 +40,13 @@ fn test_writable_roots_constraint() { assert!(is_write_patch_constrained_to_writable_paths( &add_inside, &workspace_only_file_system_policy, - &cwd, + &cwd_uri, )); assert!(!is_write_patch_constrained_to_writable_paths( &add_outside, &workspace_only_file_system_policy, - &cwd, + &cwd_uri, )); // With the parent dir explicitly added as a writable root, the @@ -56,7 +59,7 @@ fn test_writable_roots_constraint() { assert!(is_write_patch_constrained_to_writable_paths( &add_outside, &file_system_policy_with_parent, - &cwd, + &cwd_uri, )); } @@ -64,8 +67,12 @@ fn test_writable_roots_constraint() { fn external_sandbox_auto_approves_in_on_request() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let add_inside_path = cwd.join("inner.txt"); - let add_inside = ApplyPatchAction::new_add_for_test(&add_inside_path, "".to_string()); + let add_inside = ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&add_inside_path), + "".to_string(), + ); let permission_profile = PermissionProfile::External { network: NetworkSandboxPolicy::Enabled, @@ -78,7 +85,7 @@ fn external_sandbox_auto_approves_in_on_request() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled ), SafetyCheck::AutoApprove { @@ -92,9 +99,11 @@ fn external_sandbox_auto_approves_in_on_request() { fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); - let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); + let add_outside = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&outside_path), "".to_string()); let permission_profile = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -109,7 +118,7 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -126,7 +135,7 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { }), &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -137,9 +146,11 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { fn granular_sandbox_approval_false_rejects_out_of_root_patch() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); - let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); + let add_outside = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&outside_path), "".to_string()); let permission_profile = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -160,7 +171,7 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { }), &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::Reject { @@ -173,15 +184,17 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { fn read_only_policy_rejects_patch_with_read_only_reason() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let inside_path = cwd.join("inside.txt"); - let action = ApplyPatchAction::new_add_for_test(&inside_path, "".to_string()); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&inside_path), "".to_string()); let permission_profile = PermissionProfile::read_only(); let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -189,7 +202,7 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { AskForApproval::Never, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::Reject { @@ -201,9 +214,13 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let blocked_path = cwd.join("blocked.txt"); let blocked_absolute = blocked_path; - let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); + let action = ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&blocked_absolute), + "".to_string(), + ); let permission_profile = PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }; @@ -225,7 +242,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -233,7 +250,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -244,10 +261,14 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let blocked_path = cwd.join("docs").join("blocked.txt"); let blocked_absolute = blocked_path; let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd); - let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); + let action = ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&blocked_absolute), + "".to_string(), + ); let permission_profile = PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }; @@ -269,7 +290,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -277,7 +298,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -288,8 +309,10 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { fn missing_project_dot_codex_config_requires_approval() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let config_path = cwd.join(".codex").join("config.toml"); - let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string()); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&config_path), "".to_string()); let permission_profile = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -309,7 +332,7 @@ fn missing_project_dot_codex_config_requires_approval() { assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -317,7 +340,7 @@ fn missing_project_dot_codex_config_requires_approval() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f1f53d80efbe..d0006f8657ed 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -10,11 +10,11 @@ ExecRequest for execution. use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::StdoutStream; -use crate::exec::WindowsSandboxFilesystemOverrides; 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; @@ -24,7 +24,9 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxType; +use codex_sandboxing::WindowsSandboxFilesystemOverrides; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; #[derive(Debug)] @@ -42,14 +44,15 @@ pub(crate) struct ExecServerEnvConfig { #[derive(Debug)] pub struct ExecRequest { pub command: Vec, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub env: HashMap, pub(crate) exec_server_env_config: Option, pub network: Option, + pub network_environment_id: Option, pub expiration: ExecExpiration, pub capture_policy: ExecCapturePolicy, pub sandbox: SandboxType, - pub windows_sandbox_policy_cwd: AbsolutePathBuf, + pub windows_sandbox_policy_cwd: PathUri, pub windows_sandbox_workspace_roots: Vec, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -58,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 { @@ -67,6 +72,7 @@ impl ExecRequest { cwd: AbsolutePathBuf, env: HashMap, network: Option, + network_environment_id: Option, expiration: ExecExpiration, capture_policy: ExecCapturePolicy, sandbox: SandboxType, @@ -76,6 +82,7 @@ impl ExecRequest { permission_profile: PermissionProfile, arg0: Option, ) -> Self { + let cwd = PathUri::from_abs_path(&cwd); let windows_sandbox_policy_cwd = cwd.clone(); let (file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); @@ -85,6 +92,7 @@ impl ExecRequest { env, exec_server_env_config: None, network, + network_environment_id, expiration, capture_policy, sandbox, @@ -97,6 +105,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } @@ -111,6 +121,7 @@ impl ExecRequest { sandbox_policy_cwd: windows_sandbox_policy_cwd, mut env, network, + network_environment_id, sandbox, windows_sandbox_level, windows_sandbox_private_desktop, @@ -139,6 +150,7 @@ impl ExecRequest { env, exec_server_env_config: None, network, + network_environment_id, expiration, capture_policy, sandbox, @@ -151,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/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 5e21c394cafe..9510e6823551 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -1,11 +1,16 @@ use anyhow::Context; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::OrchestratorFeatureToml; +use codex_config::config_toml::OrchestratorToml; use codex_config::types::MemoriesToml; +use codex_features::CurrentTimeReminderConfigToml; use codex_features::Feature; 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; @@ -150,6 +155,24 @@ 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")?; + rollout_budget.enabled = Some(config.features.enabled(Feature::RolloutBudget)); + features.rollout_budget = Some(FeatureToml::Config(rollout_budget)); + } + if let Some(current_time_reminder) = config.current_time_reminder.as_ref() { + let mut current_time_reminder: CurrentTimeReminderConfigToml = + resolved_config_to_toml(current_time_reminder, "features.current_time_reminder")?; + current_time_reminder.enabled = Some(config.features.enabled(Feature::CurrentTimeReminder)); + features.current_time_reminder = Some(FeatureToml::Config(current_time_reminder)); + } lock_config.memories = Some(resolved_config_to_toml::( &config.memories, "memories", @@ -171,6 +194,18 @@ fn save_config_resolved_fields( .skills .get_or_insert_with(Default::default) .include_instructions = Some(config.include_skill_instructions); + lock_config + .orchestrator + .get_or_insert_with(OrchestratorToml::default) + .skills + .get_or_insert_with(OrchestratorFeatureToml::default) + .enabled = Some(config.orchestrator_skills_enabled); + lock_config + .orchestrator + .get_or_insert_with(OrchestratorToml::default) + .mcp + .get_or_insert_with(Default::default) + .enabled = Some(config.orchestrator_mcp_enabled); Ok(()) } @@ -211,6 +246,31 @@ mod tests { #[tokio::test] 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_at_remaining_tokens: vec![50_000, 25_000, 10_000], + sampling_token_weight: 1.0, + prefill_token_weight: 0.25, + }); + config + .features + .enable(Feature::RolloutBudget) + .expect("rollout_budget should be enableable in tests"); + config.current_time_reminder = Some(crate::config::CurrentTimeReminderConfig::default()); + config + .features + .enable(Feature::CurrentTimeReminder) + .expect("current_time_reminder should be enableable in tests"); + sc.original_config_do_not_use = Arc::new(config); sc.base_instructions = "resolved instructions".to_string(); sc.developer_instructions = Some("resolved developer instructions".to_string()); sc.compact_prompt = Some("resolved compact prompt".to_string()); @@ -269,12 +329,41 @@ 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_at_remaining_tokens: Some(vec![50_000, 25_000, 10_000]), + sampling_token_weight: Some(1.0), + prefill_token_weight: Some(0.25), + })) + ); + assert_eq!( + features.current_time_reminder, + Some(FeatureToml::Config(CurrentTimeReminderConfigToml { + enabled: Some(true), + reminder_interval_model_requests: Some(1), + clock_source: Some(codex_features::CurrentTimeSource::System), + })) + ); + assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION); } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index e91bf015a582..b4660d9397ea 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -125,6 +125,7 @@ async fn thread_settings_update( summary, service_tier, collaboration_mode, + multi_agent_mode, personality, } = thread_settings; let collaboration_mode = match collaboration_mode { @@ -150,6 +151,7 @@ async fn thread_settings_update( active_permission_profile, windows_sandbox_level, collaboration_mode: Some(collaboration_mode), + multi_agent_mode, reasoning_summary: summary, service_tier, personality, @@ -177,6 +179,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { reasoning_summary: snapshot.reasoning_summary, personality: snapshot.personality, collaboration_mode: snapshot.collaboration_mode, + multi_agent_mode: snapshot.multi_agent_mode, }, }) } @@ -541,6 +544,10 @@ pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32 .collect::>(); sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice()) .await; + sess.services + .agent_control + .rollout_budget() + .rearm_reminder(sess.thread_id()); sess.recompute_token_usage(turn_context.as_ref()).await; sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())]) diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cc6e42f85222..d03576aadc81 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -16,7 +16,7 @@ use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_T use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY; use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY; use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY; -use rmcp::model::CreateElicitationRequestParams; +use codex_rmcp_client::Elicitation; use rmcp::model::ElicitationAction; use rmcp::model::Meta; use serde_json::Map; @@ -143,6 +143,15 @@ impl Session { requested_schema, } } + McpServerElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + } => codex_protocol::approvals::ElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + }, McpServerElicitationRequest::Url { meta, message, @@ -318,17 +327,18 @@ impl Session { ) .await; let environment_manager = self.services.turn_environments.environment_manager(); - let mcp_runtime_context = match turn_context.environments.primary() { - Some(turn_environment) => McpRuntimeContext::new( - Arc::clone(&environment_manager), - turn_environment.cwd().to_path_buf(), - ), - None => McpRuntimeContext::new( - environment_manager, + // TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment cwd + // values can be used without falling back to the legacy host cwd. + let cwd = turn_context + .environments + .primary() + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) + .map(|cwd| cwd.to_path_buf()) + .unwrap_or_else(|| { #[allow(deprecated)] - turn_context.cwd.to_path_buf(), - ), - }; + turn_context.cwd.to_path_buf() + }); + let mcp_runtime_context = McpRuntimeContext::new(environment_manager, cwd); let mcp_startup_cancellation_token = { let mut guard = self.services.mcp_startup_cancellation_token.lock().await; guard.cancel(); @@ -352,6 +362,9 @@ impl Session { host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + self.services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, @@ -418,6 +431,34 @@ impl Session { .await; } + pub(crate) async fn set_openai_form_elicitation_support( + &self, + supported: bool, + ) -> anyhow::Result<()> { + if self + .services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed) + == supported + { + return Ok(()); + } + + let config = self.get_config().await; + let refresh_config = McpServerRefreshConfig { + mcp_servers: serde_json::to_value(config.mcp_servers.get())?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + )?, + auth_keyring_backend_kind: serde_json::to_value(config.auth_keyring_backend_kind())?, + }; + self.services + .supports_openai_form_elicitation + .store(supported, std::sync::atomic::Ordering::Relaxed); + *self.pending_mcp_server_refresh_config.lock().await = Some(refresh_config); + Ok(()) + } + pub(crate) async fn refresh_mcp_servers_now( &self, turn_context: &TurnContext, @@ -509,12 +550,15 @@ fn guardian_elicitation_review_request( request: &ElicitationReviewRequest, ) -> GuardianElicitationReview { let (meta, requested_schema) = match &request.elicitation { - CreateElicitationRequestParams::FormElicitationParams { + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { meta, requested_schema, .. - } => (meta, Some(requested_schema)), - CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => { + }) => (meta, Some(requested_schema)), + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta, + .. + }) => { return if meta_requests_approval_request(meta) { GuardianElicitationReview::Decline( "guardian MCP elicitation review only supports form elicitations", @@ -523,6 +567,7 @@ fn guardian_elicitation_review_request( GuardianElicitationReview::NotRequested }; } + Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested, }; let Some(meta) = meta.as_ref().map(|meta| &meta.0) else { @@ -584,13 +629,10 @@ fn guardian_elicitation_review_request( )) } -fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> { - match elicitation { - CreateElicitationRequestParams::FormElicitationParams { meta, .. } - | CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta - .as_ref() - .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)), - } +fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> { + elicitation + .meta() + .and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY)) } fn meta_requests_approval_request(meta: &Option) -> bool { diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index 0e3664019b84..e5d0184bb7e4 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -30,13 +30,15 @@ fn form_request(meta: Option) -> ElicitationReviewRequest { ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(7), - elicitation: CreateElicitationRequestParams::FormElicitationParams { - meta, - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta, + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + ), } } @@ -171,12 +173,14 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let url_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(8), - elicitation: CreateElicitationRequestParams::UrlElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Open URL".to_string(), - url: "https://example.com".to_string(), - elicitation_id: "elicit-1".to_string(), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Open URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "elicit-1".to_string(), + }, + ), }; assert!(matches!( guardian_elicitation_review_request(&url_request), @@ -186,14 +190,16 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let non_empty_schema_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(9), - elicitation: CreateElicitationRequestParams::FormElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .expect("schema should build"), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .expect("schema should build"), + }, + ), }; assert!(matches!( guardian_elicitation_review_request(&non_empty_schema_request), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b9cec1cb0e79..1a4fac87a922 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -27,9 +27,12 @@ use crate::context::AvailablePluginsInstructions; use crate::context::AvailableSkillsInstructions; use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; +use crate::context::MultiAgentModeInstructions; use crate::context::NetworkRuleSaved; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; +use crate::context::RecommendedPluginsInstructions; +use crate::current_time::TimeProvider; use crate::default_skill_metadata_budget; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::exec_policy::ExecPolicyManager; @@ -37,7 +40,7 @@ use crate::image_preparation::prepare_response_items; use crate::parse_turn_item; use crate::realtime_conversation::RealtimeConversationManager; use crate::session::turn_context::TurnEnvironment; -use crate::session_prefix::format_subagent_notification_message; +use crate::session_prefix::format_inter_agent_completion_message; use crate::skills::SkillRenderSideEffects; use crate::skills_load_input_from_config; use crate::turn_metadata::TurnMetadataState; @@ -57,7 +60,9 @@ use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_extension_api::ExtensionDataInit; use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::PromptFragment; use codex_extension_api::PromptSlot; +use codex_extension_api::TurnContextContributionInput; use codex_features::FEATURES; use codex_features::Feature; use codex_features::unstable_features_warning_event; @@ -88,6 +93,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; @@ -148,7 +154,6 @@ use codex_thread_store::ReadThreadParams; use codex_thread_store::ResumeThreadParams; use codex_thread_store::ThreadPersistenceMetadata; use codex_thread_store::ThreadStore; -use codex_utils_output_truncation::TruncationPolicy; use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use futures::future::Shared; @@ -197,7 +202,6 @@ use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStackOrdering; use codex_config::types::McpServerConfig; use codex_model_provider_info::ModelProviderInfo; -use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] @@ -209,11 +213,14 @@ mod inject; mod input_queue; mod mcp; mod model_router_turn; -mod multi_agents; +pub(crate) mod multi_agents; mod review; +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; @@ -293,11 +300,9 @@ pub(crate) struct PreviousTurnSettings { pub(crate) realtime_active: Option, } -#[cfg(test)] -use crate::SkillLoadOutcome; #[cfg(test)] use crate::SkillMetadata; -use crate::SkillsManager; +use crate::SkillsService; use crate::agents_md::load_project_instructions; use crate::exec_policy::ExecPolicyUpdateError; use crate::guardian::GuardianReviewSessionManager; @@ -306,6 +311,9 @@ use crate::network_policy_decision::execpolicy_network_rule_amendment; use crate::rollout::map_session_init_error; 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; @@ -326,6 +334,7 @@ use crate::turn_timing::record_turn_ttfm_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; +use codex_core_plugins::RecommendedPluginCandidatesInput; use codex_git_utils::get_git_repo_root; use codex_mcp::McpConfig; use codex_mcp::compute_auth_statuses; @@ -410,7 +419,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) auth_manager: Arc, pub(crate) models_manager: SharedModelsManager, pub(crate) environment_manager: Arc, - pub(crate) skills_manager: Arc, + pub(crate) skills_service: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, pub(crate) extensions: Arc>, @@ -433,10 +442,13 @@ pub(crate) struct CodexSpawnArgs { pub(crate) parent_trace: Option, pub(crate) environment_selections: Vec, pub(crate) thread_extension_init: ExtensionDataInit, + pub(crate) supports_openai_form_elicitation: bool, pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, + pub(crate) external_time_provider: Option>, pub(crate) inherited_multi_agent_version: Option, + pub(crate) initial_multi_agent_mode: Option, } pub(crate) fn resolve_multi_agent_version( @@ -496,7 +508,7 @@ impl Codex { auth_manager, models_manager, environment_manager, - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions, @@ -515,10 +527,13 @@ impl Codex { parent_trace: _, environment_selections, thread_extension_init, + supports_openai_form_elicitation, analytics_events_client, thread_store, attestation_provider, + external_time_provider, inherited_multi_agent_version, + initial_multi_agent_mode, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -573,6 +588,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.unwrap_or_default(); config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; @@ -606,6 +622,7 @@ impl Codex { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode, model_reasoning_summary: config.model_reasoning_summary, service_tier, developer_instructions: config.developer_instructions.clone(), @@ -652,11 +669,12 @@ impl Codex { agent_status_tx.clone(), conversation_history, session_source_clone, - skills_manager, + skills_service, plugins_manager, mcp_manager.clone(), extensions, thread_extension_init, + supports_openai_form_elicitation, agent_control, environment_manager, inherited_environments, @@ -664,6 +682,7 @@ impl Codex { thread_store, parent_rollout_thread_trace, attestation_provider, + external_time_provider, multi_agent_version, )) .await @@ -820,15 +839,13 @@ impl Codex { state.session_configuration.thread_config_snapshot() } - pub(crate) async fn instruction_sources(&self) -> Vec { + pub(crate) async fn instruction_sources(&self) -> Vec { let state = self.session.state.lock().await; state .session_configuration .loaded_agents_md .as_ref() - .map_or_else(Vec::new, |instructions| { - instructions.sources().cloned().collect() - }) + .map_or_else(Vec::new, |instructions| instructions.sources().collect()) } pub(crate) async fn thread_environment_selections(&self) -> Vec { @@ -912,6 +929,25 @@ async fn thread_title_from_thread_store( (!title.is_empty() && thread.preview.trim() != title).then(|| title.to_string()) } +fn push_prompt_fragment( + fragment: PromptFragment, + developer_sections: &mut Vec, + contextual_user_sections: &mut Vec, + separate_developer_sections: &mut Vec, +) { + match fragment.slot() { + PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { + developer_sections.push(fragment.text().to_string()); + } + PromptSlot::ContextualUser => { + contextual_user_sections.push(fragment.text().to_string()); + } + PromptSlot::SeparateDeveloper => { + separate_developer_sections.push(fragment.text().to_string()); + } + } +} + impl Session { pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata { let state = self.state.lock().await; @@ -1135,7 +1171,7 @@ impl Session { pub(crate) async fn route_realtime_text_input(self: &Arc, text: String) { handlers::user_input_or_turn_inner( self, - self.next_internal_sub_id(), + Uuid::now_v7().to_string(), Op::UserInput { items: vec![UserInput::Text { text, @@ -1200,6 +1236,11 @@ impl Session { } // Merges connector IDs into the session-level explicit connector selection. + #[tracing::instrument( + level = "trace", + skip_all, + fields(connector_count = connector_ids.len()) + )] pub(crate) async fn merge_connector_selection( &self, connector_ids: HashSet, @@ -1325,21 +1366,31 @@ impl Session { mut history, 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.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 mut state = self.state.lock().await; state.replace_history(history, reference_context_item); - state.set_auto_compact_window_id(window_id); + 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!( @@ -1387,6 +1438,7 @@ impl Session { state.previous_turn_settings() } + #[tracing::instrument(level = "trace", skip_all)] pub(crate) async fn set_previous_turn_settings( &self, previous_turn_settings: Option, @@ -1504,7 +1556,7 @@ impl Session { (previous_config, new_config, config) }; self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); - self.services.skills_manager.clear_cache(); + self.services.skills_service.clear_cache(); self.services.plugins_manager.clear_cache(); let environments = self.services.turn_environments.snapshot().await; let hooks = build_hooks_for_config( @@ -1655,6 +1707,18 @@ impl Session { /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); + if let EventMsg::Error(error) = &legacy_source + && error + .codex_error_info + .as_ref() + .is_some_and(CodexErrorInfo::affects_turn_status) + { + turn_context + .terminal_error + .lock() + .await + .replace(error.message.clone()); + } self.services .rollout_thread_trace .record_codex_turn_event(&turn_context.sub_id, &legacy_source); @@ -1706,8 +1770,18 @@ impl Session { return; }; - let Some(status) = agent_status_from_event(msg) else { - return; + let status = match turn_context.terminal_error.lock().await.take() { + Some(error) => { + let status = AgentStatus::Errored(error); + self.agent_status.send_replace(status.clone()); + status + } + None => { + let Some(status) = agent_status_from_event(msg) else { + return; + }; + status + } }; if !is_final(&status) { return; @@ -1738,7 +1812,13 @@ impl Session { return; }; - let message = format_subagent_notification_message(child_agent_path.as_str(), &status); + let Some(message) = format_inter_agent_completion_message( + parent_agent_path.clone(), + child_agent_path.clone(), + &status, + ) else { + return; + }; // `communication` owns the message. Keep a second copy only when the // recorder will actually need it after parent delivery succeeds. let trace_message = self @@ -1778,13 +1858,13 @@ impl Session { } async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) { - let Some(text) = realtime_text_for_event(msg) else { + let Some((text, phase)) = realtime_text_for_event(msg) else { return; }; if self.conversation.running_state().await.is_none() { return; } - if let Err(err) = self.conversation.handoff_out(text).await { + if let Err(err) = self.conversation.handoff_out(text, phase).await { debug!("failed to mirror event text to realtime conversation: {err}"); } } @@ -2008,6 +2088,7 @@ impl Session { turn_context: &TurnContext, call_id: String, approval_id: Option, + environment_id: Option, command: Vec, cwd: AbsolutePathBuf, reason: Option, @@ -2060,6 +2141,7 @@ impl Session { call_id, approval_id, turn_id: turn_context.sub_id.clone(), + environment_id, started_at_ms: now_unix_timestamp_ms(), command, cwd, @@ -2601,31 +2683,54 @@ impl Session { turn_context: &TurnContext, items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { - if !turn_context.features.enabled(Feature::ResizeAllImages) { - return Cow::Borrowed(items); + let mut items = Cow::Borrowed(items); + prepare_response_items(items.to_mut()); + if turn_context.config.features.enabled(Feature::ItemIds) { + Self::assign_missing_response_item_ids(items) + } else { + items } + } - let mut prepared_items = items.to_vec(); - prepare_response_items(&mut prepared_items); - Cow::Owned(prepared_items) + fn assign_missing_response_item_ids(items: Cow<'_, [ResponseItem]>) -> Cow<'_, [ResponseItem]> { + if items.iter().all(|item| item.id().is_some()) { + return items; + } + let mut items = items; + for item in items.to_mut() { + if item.id().is_some() { + continue; + } + let prefix = match item { + ResponseItem::Message { .. } => "msg", + ResponseItem::Reasoning { .. } => "rs", + ResponseItem::LocalShellCall { .. } => "lsh", + ResponseItem::FunctionCall { .. } => "fc", + ResponseItem::ToolSearchCall { .. } => "tsc", + ResponseItem::FunctionCallOutput { .. } => "fco", + ResponseItem::CustomToolCall { .. } => "ctc", + ResponseItem::CustomToolCallOutput { .. } => "ctco", + ResponseItem::ToolSearchOutput { .. } => "tso", + ResponseItem::WebSearchCall { .. } => "ws", + ResponseItem::ImageGenerationCall { .. } => "ig", + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => "cmp", + ResponseItem::AgentMessage { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::Other => continue, + }; + item.set_id(Some(format!("{prefix}_{}", Uuid::now_v7()))); + } + items } - pub(crate) fn response_item_from_user_input( - &self, - turn_context: &TurnContext, - input: Vec, - ) -> ResponseItem { - let local_image_preparation = if turn_context.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, )) } + #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] pub(crate) async fn record_conversation_items( &self, turn_context: &TurnContext, @@ -2635,12 +2740,44 @@ impl Session { let items = items.as_ref(); { let mut state = self.state.lock().await; - state.record_items(items.iter(), turn_context.truncation_policy); + state.record_items( + items.iter(), + turn_context.model_info.truncation_policy.into(), + ); } self.persist_rollout_response_items(items).await; 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 shell = self.user_shell(); + let Some(environment_context) = + crate::context::EnvironmentContext::from_step_context(step_context, shell.as_ref()) + else { + return; + }; + let changed = { + let mut state = self.state.lock().await; + state + .history + .update_environment_context_baseline(&environment_context) + }; + if !changed { + return; + } + + let item = ContextualUserFragment::into(environment_context); + self.record_conversation_items(turn_context, &[item]).await; + } + pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, @@ -2654,7 +2791,10 @@ impl Session { let items = items.as_ref(); { let mut state = self.state.lock().await; - state.record_items(items.iter(), turn_context.truncation_policy); + state.record_items( + items.iter(), + turn_context.model_info.truncation_policy.into(), + ); } self.persist_rollout_items(&[RolloutItem::InterAgentCommunication(communication)]) .await; @@ -2733,10 +2873,20 @@ impl Session { pub(crate) async fn replace_compacted_history( &self, + turn_context: &TurnContext, items: Vec, reference_context_item: Option, compacted_item: CompactedItem, ) { + let items = if turn_context.config.features.enabled(Feature::ItemIds) { + Self::assign_missing_response_item_ids(Cow::Owned(items)).into_owned() + } else { + items + }; + let compacted_item = CompactedItem { + replacement_history: Some(items.clone()), + ..compacted_item + }; { let mut state = self.state.lock().await; state.replace_history(items, reference_context_item.clone()); @@ -2803,6 +2953,7 @@ impl Session { self.set_multi_agent_version_if_unset(selected) } + #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( @@ -2813,6 +2964,57 @@ impl Session { } } + async fn build_turn_context_contribution_items( + &self, + turn_context: &TurnContext, + ) -> Vec { + let mut developer_sections = Vec::new(); + let mut contextual_user_sections = Vec::new(); + let mut separate_developer_sections = Vec::new(); + let context_contributors = self.services.extensions.context_contributors().to_vec(); + + for contributor in &context_contributors { + for fragment in contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: self.thread_id(), + turn_id: turn_context.sub_id.as_str(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + model_context_window: turn_context.model_context_window(), + }) + .await + { + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); + } + } + + let mut items = Vec::with_capacity(3); + if let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(developer_sections) + { + items.push(developer_message); + } + for section in separate_developer_sections { + if let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(vec![section]) + { + items.push(developer_message); + } + } + if let Some(contextual_user_message) = + crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) + { + items.push(contextual_user_message); + } + items + } + pub(crate) async fn build_initial_context( &self, turn_context: &TurnContext, @@ -2826,7 +3028,7 @@ impl Session { collaboration_mode, base_instructions, session_source, - auto_compact_window_id, + auto_compact_window_ids, ) = { let state = self.state.lock().await; ( @@ -2835,7 +3037,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) = @@ -2856,9 +3058,11 @@ impl Session { #[allow(deprecated)] &turn_context.cwd, turn_context + .config .features .enabled(Feature::ExecPermissionApprovals), turn_context + .config .features .enabled(Feature::RequestPermissionsTool), ) @@ -2922,7 +3126,7 @@ impl Session { } if turn_context.config.include_skill_instructions { let available_skills = build_available_skills( - &turn_context.turn_skills.outcome, + turn_context.turn_skills.snapshot.outcome(), default_skill_metadata_budget(turn_context.model_info.context_window), SkillRenderSideEffects::ThreadStart { session_telemetry: &self.services.session_telemetry, @@ -2948,45 +3152,108 @@ impl Session { .plugins_manager .plugins_for_config(&turn_context.config.plugins_config_input()) .await; + let recommended_plugin_candidates = + if crate::tools::spec_plan::tool_suggest_enabled(turn_context) { + let auth = self.services.auth_manager.auth().await; + let plugins_config = turn_context.config.plugins_config_input(); + self.services + .plugins_manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &plugins_config, + loaded_plugins: &loaded_plugins, + auth: auth.as_ref(), + disabled_tools: &turn_context.config.tool_suggest.disabled_tools, + app_server_client_name: turn_context.app_server_client_name.as_deref(), + }) + .await + } else { + None + }; + if let Some(recommended_plugins) = recommended_plugin_candidates + .as_deref() + .and_then(RecommendedPluginsInstructions::from_plugins) + { + contextual_user_sections.push(recommended_plugins.render()); + } if let Some(plugin_instructions) = AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries()) { developer_sections.push(plugin_instructions.render()); } let context_contributors = self.services.extensions.context_contributors().to_vec(); - for contributor in context_contributors { + for contributor in &context_contributors { for fragment in contributor - .contribute( + .contribute_thread_context( &self.services.session_extension_data, &self.services.thread_extension_data, ) .await { - match fragment.slot() { - PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { - developer_sections.push(fragment.text().to_string()); - } - PromptSlot::ContextualUser => { - contextual_user_sections.push(fragment.text().to_string()); - } - PromptSlot::SeparateDeveloper => { - separate_developer_sections.push(fragment.text().to_string()); - } - } + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); + } + } + for contributor in &context_contributors { + for fragment in contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: self.thread_id(), + turn_id: turn_context.sub_id.as_str(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + model_context_window: turn_context.model_context_window(), + }) + .await + { + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); } } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { contextual_user_sections.push(user_instructions.to_string()); } // This is full-context metadata. Steady-state context diffs should not re-emit it. - if turn_context.features.enabled(Feature::TokenBudget) - && let Some(model_context_window) = turn_context.model_context_window() + if turn_context.config.features.enabled(Feature::TokenBudget) + && 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(), ); @@ -3029,6 +3296,21 @@ impl Session { { items.push(usage_hint_message); } + match multi_agents::effective_multi_agent_mode( + turn_context.multi_agent_version, + &session_source, + turn_context.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) { @@ -3049,6 +3331,7 @@ impl Session { items } + #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) { if let Some(live_thread) = self.live_thread() && let Err(e) = live_thread.append_items(items).await @@ -3065,13 +3348,13 @@ impl Session { pub(crate) async fn current_window_id(&self) -> String { let state = self.state.lock().await; let thread_id = self.thread_id; - let window_id = state.auto_compact_window_id(); - format!("{thread_id}:{window_id}") + let window_number = state.auto_compact_window_number(); + format!("{thread_id}:{window_number}") } - pub(crate) async fn advance_auto_compact_window_id(&self) -> u64 { + pub(crate) async fn advance_auto_compact_window(&self) -> (u64, AutoCompactWindowIds) { let mut state = self.state.lock().await; - state.advance_auto_compact_window_id() + state.advance_auto_compact_window() } pub(crate) async fn request_new_context_window(&self) { @@ -3083,11 +3366,11 @@ impl Session { &self, turn_context: &TurnContext, ) -> Option { - let window_id = { + let window = { let mut state = self.state.lock().await; state.start_new_context_window_if_requested() }; - let window_id = window_id?; + let (window_number, window_ids) = window?; let context_items = self.build_initial_context(turn_context).await; let turn_context_item = turn_context.to_turn_context_item(); let replacement_history = context_items; @@ -3099,7 +3382,10 @@ impl Session { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history), - window_id: Some(window_id), + window_number: Some(window_number), + 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), ]) @@ -3109,7 +3395,7 @@ impl Session { state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact); } self.recompute_token_usage(turn_context).await; - Some(window_id) + Some(window_number) } pub(crate) async fn reference_context_item(&self) -> Option { @@ -3139,15 +3425,41 @@ impl Session { let state = self.state.lock().await; 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 should_inject_full_context = reference_context_item.is_none(); - let context_items = if should_inject_full_context { + let mut context_items = if should_inject_full_context { self.build_initial_context(turn_context).await } else { - // Steady-state path: append only context diffs to minimize token overhead. + // 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 turn_context_item = turn_context.to_turn_context_item(); + if !should_inject_full_context { + context_items.extend( + self.build_turn_context_contribution_items(turn_context) + .await, + ); + } + let initial_environment_context = if should_inject_full_context + && !context_items.is_empty() + && turn_context.config.include_environment_context + && turn_context + .config + .features + .enabled(Feature::DeferredExecutor) + { + let shell = self.user_shell(); + crate::context::EnvironmentContext::from_attached_environments( + &turn_context.environments.turn_environments, + shell.as_ref(), + ) + } else { + None + }; if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; @@ -3161,23 +3473,30 @@ impl Session { // context items. This keeps later runtime diffing aligned with the current turn state. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); + if let Some(environment_context) = initial_environment_context { + state + .history + .update_environment_context_baseline(&environment_context); + } } pub(crate) async fn update_token_usage_info( &self, turn_context: &TurnContext, token_usage: Option<&TokenUsage>, - ) { - self.record_token_usage_info(turn_context, token_usage) + ) -> CodexResult<()> { + let result = self + .record_token_usage_info(turn_context, token_usage) .await; self.send_token_count_event(turn_context).await; + result } pub(crate) async fn record_token_usage_info( &self, turn_context: &TurnContext, token_usage: Option<&TokenUsage>, - ) { + ) -> CodexResult<()> { if let Some(token_usage) = token_usage { let token_info = { let mut state = self.state.lock().await; @@ -3191,6 +3510,7 @@ impl Session { } state.token_info() }; + let budget_result = self.record_rollout_budget_usage(token_usage); if let Some(token_info) = token_info.as_ref() { for contributor in self.services.extensions.token_usage_contributors() { contributor @@ -3203,7 +3523,9 @@ impl Session { .await; } } + budget_result?; } + Ok(()) } pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) { @@ -3320,7 +3642,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/model_router_turn.rs b/codex-rs/core/src/session/model_router_turn.rs index 10483152b52d..26abeb7ccb66 100644 --- a/codex-rs/core/src/session/model_router_turn.rs +++ b/codex-rs/core/src/session/model_router_turn.rs @@ -165,17 +165,21 @@ impl Session { .plugins_for_config(&per_turn_config.plugins_config_input()) .await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); + let plugin_skill_snapshots = self + .services + .plugins_manager + .plugin_skill_snapshots_for_config(&per_turn_config.plugins_config_input()); + let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let fs = previous .environments .primary() .map(|turn_environment| turn_environment.environment.get_filesystem()); - let skills_outcome = Arc::new( - self.services - .skills_manager - .skills_for_config(&skills_input, fs) - .await, - ); + let skills_snapshot = self + .services + .skills_service + .snapshot_for_config(&skills_input, fs) + .await; let mut rebuilt = Self::make_turn_context( self.thread_id(), self.session_id(), @@ -194,7 +198,7 @@ impl Session { previous.environments.clone(), previous.cwd.clone(), previous.sub_id.clone(), - skills_outcome, + skills_snapshot, ); rebuilt.trace_id = previous.trace_id.clone(); rebuilt.realtime_active = previous.realtime_active; diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index a78a9c938eef..82fcfd128276 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,4 +1,6 @@ +use crate::config::MultiAgentV2Config; use crate::session::turn_context::TurnContext; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -12,10 +14,13 @@ 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) +} +fn configured_usage_hint_text_for_source<'a>( + multi_agent_v2: &'a MultiAgentV2Config, + session_source: &SessionSource, +) -> Option<&'a str> { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => { multi_agent_v2.subagent_usage_hint_text.as_deref() @@ -29,3 +34,24 @@ pub(super) fn usage_hint_text<'a>( SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, } } + +pub(crate) fn effective_multi_agent_mode( + multi_agent_version: MultiAgentVersion, + session_source: &SessionSource, + multi_agent_mode: MultiAgentMode, +) -> Option { + if multi_agent_version != MultiAgentVersion::V2 { + return None; + } + + 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 9a7dfc829894..8986ffd052c1 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,6 +1,4 @@ use super::*; -use codex_core_skills::HostLoadedSkills; -use codex_protocol::openai_models::ToolMode; use std::sync::atomic::AtomicBool; /// Spawn a review thread using the given prompt. @@ -47,15 +45,14 @@ pub(super) async fn spawn_review_thread( let mut per_turn_config = (*config).clone(); per_turn_config.model = Some(model.clone()); per_turn_config.features = review_features.clone(); - let tool_mode = model_info.tool_mode.unwrap_or_else(|| { - if per_turn_config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if per_turn_config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); + per_turn_config.permissions.shell_environment_policy = parent_turn_context + .config + .permissions + .shell_environment_policy + .clone(); + per_turn_config.codex_linux_sandbox_exe = + parent_turn_context.config.codex_linux_sandbox_exe.clone(); + per_turn_config.compact_prompt = parent_turn_context.config.compact_prompt.clone(); if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) { let fallback_value = per_turn_config.web_search_mode.value(); tracing::warn!( @@ -78,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); @@ -91,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(), @@ -102,9 +103,7 @@ pub(super) async fn spawn_review_thread( let extension_data = Arc::new(codex_extension_api::ExtensionData::new( review_turn_id.clone(), )); - extension_data.insert(HostLoadedSkills::new( - parent_turn_context.turn_skills.outcome.clone(), - )); + extension_data.insert(parent_turn_context.turn_skills.snapshot.clone()); let review_turn_context = TurnContext { sub_id: review_turn_id.clone(), @@ -113,45 +112,37 @@ pub(super) async fn spawn_review_thread( config: per_turn_config, auth_manager: auth_manager_for_context, model_info: model_info.clone(), - comp_hash: model_info.comp_hash.clone(), - tool_mode, session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, reasoning_summary, session_source, parent_thread_id: parent_turn_context.parent_thread_id, - thread_source: parent_turn_context.thread_source.clone(), environments: parent_turn_context.environments.clone(), available_models, unified_exec_shell_mode, - features: review_features, - ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), developer_instructions: None, user_instructions: None, - compact_prompt: parent_turn_context.compact_prompt.clone(), collaboration_mode: parent_turn_context.collaboration_mode.clone(), + multi_agent_mode: parent_turn_context.multi_agent_mode, multi_agent_version: MultiAgentVersion::Disabled, personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy.clone(), permission_profile: parent_turn_context.permission_profile(), network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, - shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), #[allow(deprecated)] cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, - codex_self_exe: parent_turn_context.codex_self_exe.clone(), - codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), dynamic_tools: parent_turn_context.dynamic_tools.clone(), - truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, extension_data, - turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), + turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.snapshot.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), + terminal_error: Arc::new(Mutex::new(None)), server_model_warning_emitted: AtomicBool::new(false), model_verification_emitted: AtomicBool::new(false), }; diff --git a/codex-rs/core/src/session/rollout_budget.rs b/codex-rs/core/src/session/rollout_budget.rs new file mode 100644 index 000000000000..fa90cf7b977e --- /dev/null +++ b/codex-rs/core/src/session/rollout_budget.rs @@ -0,0 +1,37 @@ +use super::session::Session; +use super::turn_context::TurnContext; +use crate::context::ContextualUserFragment; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::protocol::TokenUsage; + +pub(super) async fn maybe_record_reminder( + sess: &Session, + turn_context: &TurnContext, + window_id: &str, +) { + let budget = sess.services.agent_control.rollout_budget(); + let Some(reminder) = budget.pending_reminder(sess.thread_id(), window_id) else { + return; + }; + let response_item = ContextualUserFragment::into(crate::context::RolloutBudgetContext { + remaining_tokens: reminder.remaining_tokens, + }); + sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + .await; + budget.mark_reminder_delivered(sess.thread_id(), window_id, reminder); +} + +impl Session { + pub(crate) fn record_rollout_budget_usage(&self, usage: &TokenUsage) -> CodexResult<()> { + if self + .services + .agent_control + .rollout_budget() + .record_usage(usage) + { + return Err(CodexErr::TurnAborted); + } + Ok(()) + } +} diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 34e7807a9a5a..bf534c1e8a5c 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -1,5 +1,6 @@ use super::*; use crate::context_manager::is_user_turn_boundary; +use uuid::Uuid; // Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with // the resume/fork hydration metadata derived from the same replay. @@ -8,7 +9,18 @@ pub(super) struct RolloutReconstruction { pub(super) history: Vec, pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, - pub(super) window_id: u64, + 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, } #[derive(Debug, Default)] @@ -34,7 +46,7 @@ struct ActiveReplaySegment<'a> { previous_turn_settings: Option, reference_context_item: TurnReferenceContextItem, base_replacement_history: Option<&'a [ResponseItem]>, - window_id: Option, + window: Option, } fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&str>) -> bool { @@ -47,7 +59,7 @@ fn finalize_active_segment<'a>( base_replacement_history: &mut Option<&'a [ResponseItem]>, previous_turn_settings: &mut Option, reference_context_item: &mut TurnReferenceContextItem, - window_id: &mut Option, + window: &mut Option, pending_rollback_turns: &mut usize, ) { // Thread rollback drops the newest surviving real user-message boundaries. In replay, that @@ -68,8 +80,8 @@ fn finalize_active_segment<'a>( *base_replacement_history = Some(segment_base_replacement_history); } - if window_id.is_none() { - *window_id = active_segment.window_id; + if window.is_none() { + *window = active_segment.window; } // `previous_turn_settings` come from the newest surviving user turn that established them. @@ -104,7 +116,7 @@ impl Session { let mut base_replacement_history: Option<&[ResponseItem]> = None; let mut previous_turn_settings = None; let mut reference_context_item = TurnReferenceContextItem::NeverSet; - let mut window_id = None; + let mut window = None; // Rollback is "drop the newest N user turns". While scanning in reverse, that becomes // "skip the next N user-turn segments we finalize". let mut pending_rollback_turns = 0usize; @@ -120,8 +132,18 @@ impl Session { RolloutItem::Compacted(compacted) => { let active_segment = active_segment.get_or_insert_with(ActiveReplaySegment::default); - if active_segment.window_id.is_none() { - active_segment.window_id = compacted.window_id; + if active_segment.window.is_none() + && let Some(window_number) = compacted.window_number + { + 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), + }); } // Looking backward, compaction clears any older baseline unless a newer // `TurnContextItem` in this same segment has already re-established it. @@ -210,7 +232,7 @@ impl Session { &mut base_replacement_history, &mut previous_turn_settings, &mut reference_context_item, - &mut window_id, + &mut window, &mut pending_rollback_turns, ); } @@ -245,12 +267,12 @@ impl Session { &mut base_replacement_history, &mut previous_turn_settings, &mut reference_context_item, - &mut window_id, + &mut window, &mut pending_rollback_turns, ); } - let fallback_window_id = u64::try_from( + let fallback_window_number = u64::try_from( rollout_items .iter() .filter(|item| matches!(item, RolloutItem::Compacted(_))) @@ -271,14 +293,14 @@ impl Session { RolloutItem::ResponseItem(response_item) => { history.record_items( std::iter::once(response_item), - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ); } RolloutItem::InterAgentCommunication(communication) => { let response_item = communication.to_model_input_item(); history.record_items( std::iter::once(&response_item), - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ); } RolloutItem::Compacted(compacted) => { @@ -326,11 +348,26 @@ impl Session { reference_context_item }; + 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(), previous_turn_settings, reference_context_item, - window_id: window_id.unwrap_or(fallback_window_id), + window_number: window.number, + first_window_id: window.first_id, + previous_window_id: window.previous_id, + window_id: window.id, } } } + +fn parse_uuid_v7(value: &str) -> Option { + Uuid::parse_str(value) + .ok() + .filter(|uuid| uuid.get_version_num() == 7) +} diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index d53e3866c993..b28bd37c79bd 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, } } @@ -88,7 +88,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -102,6 +102,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -128,7 +129,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -142,6 +143,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -826,6 +828,9 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::Compacted(CompactedItem { 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( @@ -883,6 +888,9 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -909,6 +917,9 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_ RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -941,6 +952,9 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::Compacted(CompactedItem { 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( @@ -989,7 +1003,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1003,6 +1017,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1035,6 +1050,9 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::Compacted(CompactedItem { 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), @@ -1071,7 +1089,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1085,6 +1103,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1101,7 +1120,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1115,6 +1134,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1185,6 +1205,9 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1223,7 +1246,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1237,6 +1260,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1343,7 +1367,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1357,6 +1381,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1419,6 +1444,9 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1506,7 +1534,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1520,6 +1548,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1583,6 +1612,9 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::Compacted(CompactedItem { 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 diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 4f0d45be0011..fd6f7301e843 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -52,6 +52,7 @@ pub(crate) struct SessionConfiguration { pub(super) provider: ModelProviderInfo, pub(super) collaboration_mode: CollaborationMode, + pub(super) multi_agent_mode: MultiAgentMode, pub(super) model_reasoning_summary: Option, pub(super) service_tier: Option, @@ -192,6 +193,7 @@ impl SessionConfiguration { reasoning_summary: self.model_reasoning_summary, personality: self.personality, collaboration_mode: self.collaboration_mode.clone(), + multi_agent_mode: self.multi_agent_mode, session_source: self.session_source.clone(), forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, @@ -228,6 +230,9 @@ impl SessionConfiguration { if let Some(collaboration_mode) = updates.collaboration_mode.clone() { next_configuration.collaboration_mode = collaboration_mode; } + if let Some(multi_agent_mode) = updates.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); } @@ -422,6 +427,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) active_permission_profile: Option, pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, + pub(crate) multi_agent_mode: Option, pub(crate) reasoning_summary: Option, pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, @@ -438,18 +444,22 @@ pub(crate) struct AppServerClientMetadata { async fn warm_plugins_and_skills_for_session_init( config: Arc, plugins_manager: Arc, - skills_manager: Arc, + skills_service: Arc, turn_environments: &TurnEnvironmentSnapshot, ) -> Vec { let fs = turn_environments.primary_filesystem(); let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots); - skills_manager - .skills_for_config(&skills_input, fs) + let plugin_skill_snapshots = plugins_manager.plugin_skill_snapshots_for_config(&plugins_input); + let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); + skills_service + .snapshot_for_config(&skills_input, fs) .await + .outcome() .errors + .clone() } impl Session { @@ -477,11 +487,12 @@ impl Session { agent_status: watch::Sender, initial_history: InitialHistory, session_source: SessionSource, - skills_manager: Arc, + skills_service: Arc, plugins_manager: Arc, mcp_manager: Arc, extensions: Arc>, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, agent_control: AgentControl, environment_manager: Arc, inherited_environments: Option, @@ -489,6 +500,7 @@ impl Session { thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, attestation_provider: Option>, + external_time_provider: Option>, multi_agent_version: Option, ) -> anyhow::Result> { debug!( @@ -513,6 +525,37 @@ 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, + )?; let mcp_thread_init = thread_extension_init.clone(); let thread_extension_data = codex_extension_api::ExtensionData::new_with_init( thread_id.to_string(), @@ -530,6 +573,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, @@ -816,6 +860,7 @@ impl Session { default_shell.clone(), shell_snapshot, inherited_environments.unwrap_or_default(), + config.features.enabled(Feature::DeferredExecutor), )); turn_environments.update_selections(session_configuration.environment_selections()); let resolved_environments = turn_environments.snapshot().await; @@ -828,7 +873,7 @@ impl Session { let plugin_skill_errors = warm_plugins_and_skills_for_session_init( Arc::clone(&config), Arc::clone(&plugins_manager), - Arc::clone(&skills_manager), + Arc::clone(&skills_service), &resolved_environments, ) .instrument(info_span!( @@ -935,17 +980,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( @@ -1000,7 +1034,7 @@ impl Session { guardian_rejections: Mutex::new(HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), - skills_manager, + skills_service, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), extensions, @@ -1008,6 +1042,9 @@ impl Session { session_extension_data, thread_extension_data, mcp_thread_init, + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new( + supports_openai_form_elicitation, + ), agent_control, network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)), network_proxy_audit_metadata, @@ -1017,6 +1054,7 @@ impl Session { live_thread: live_thread_init.as_ref().cloned(), thread_store: Arc::clone(&thread_store), attestation_provider: attestation_provider.clone(), + time_provider, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), thread_id, @@ -1026,6 +1064,7 @@ impl Session { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), attestation_provider, ) .with_prompt_cache_key_override( @@ -1120,9 +1159,12 @@ impl Session { }; let mcp_runtime_context = { let turn_environments = sess.services.turn_environments.snapshot().await; + // TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment + // cwd values can be used without falling back to the session host cwd. let cwd = turn_environments .primary() - .map(|turn_environment| turn_environment.cwd().to_path_buf()) + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) + .map(|cwd| cwd.to_path_buf()) .unwrap_or_else(|| session_configuration.cwd().to_path_buf()); McpRuntimeContext::new( sess.services.turn_environments.environment_manager(), @@ -1145,6 +1187,9 @@ impl Session { host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, + sess.services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), @@ -1173,7 +1218,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 000000000000..17243ced671b --- /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 ec1b163e6342..369968d1f61e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -24,6 +24,7 @@ use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::loader::project_trust_key; use codex_config::types::ToolSuggestDisabledTool; +use codex_core_skills::HostSkillsSnapshot; use core_test_support::test_codex::local_selections; use codex_features::Feature; @@ -41,6 +42,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; @@ -69,6 +71,7 @@ use crate::state::ActiveTurn; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; +use crate::tasks::SessionTaskResult; use crate::tasks::UserShellCommandMode; use crate::tasks::execute_user_shell_command; use crate::tasks::record_workflow_output; @@ -106,8 +109,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; @@ -169,6 +172,7 @@ use tokio::sync::Semaphore; use tokio::time::sleep; use tokio::time::timeout; use tracing_opentelemetry::OpenTelemetrySpanExt; +use uuid::Uuid; use codex_protocol::mcp::CallToolResult as McpCallToolResult; use pretty_assertions::assert_eq; @@ -194,10 +198,31 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } +#[test] +fn assign_missing_response_item_ids_skips_agent_messages() { + let items = Cow::Owned(vec![ + ResponseItem::AgentMessage { + id: None, + author: "worker".to_string(), + recipient: "root".to_string(), + content: vec![AgentMessageInputContent::InputText { + text: "done".to_string(), + }], + internal_chat_message_metadata_passthrough: None, + }, + user_message("hello"), + ]); + + let items = Session::assign_missing_response_item_ids(items); + + assert_eq!(items[0].id(), None); + assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); +} + fn assistant_message(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -206,7 +231,7 @@ fn assistant_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -266,7 +291,7 @@ fn skill_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -438,6 +463,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ) .new_session() @@ -573,9 +599,9 @@ fn test_tool_runtime(session: Arc, turn_context: Arc) -> T let router = Arc::new(ToolRouter::from_turn_context( &turn_context, crate::tools::router::ToolRouterParams { + tool_suggest_candidates: None, mcp_tools: None, deferred_mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, @@ -1593,7 +1619,13 @@ async fn reconstruct_history_matches_live_compactions() { .await; assert_eq!(expected, reconstructed.history); - assert_eq!(2, reconstructed.window_id); + assert_eq!(2, reconstructed.window_number); + assert_eq!( + reconstructed + .window_id + .map(|window_id| window_id.get_version_num()), + Some(7) + ); } #[tokio::test] @@ -1606,7 +1638,7 @@ 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()), }), }; @@ -1619,13 +1651,19 @@ 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_id: Some(42), + 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()), })]; let reconstructed = session @@ -1633,7 +1671,10 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { .await; assert_eq!(reconstructed.history, replacement_history); - assert_eq!(42, reconstructed.window_id); + 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); } #[tokio::test] @@ -1654,16 +1695,17 @@ async fn record_initial_history_reconstructs_resumed_transcript() { } #[tokio::test] -async fn resize_all_images_prepares_failures_before_history_insertion() { +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); }, ) .await; let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -1681,14 +1723,25 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&item)) .await; + let history = session.state.lock().await.clone_history(); + let id = history.raw_items()[0] + .id() + .expect("history item should have an ID") + .to_string(); + let uuid = id + .strip_prefix("fco_") + .expect("function call output ID should have the Responses API prefix"); + let parsed_id = Uuid::parse_str(uuid).expect("history item should have a UUID ID"); + assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand)); let expected = vec![ResponseItem::FunctionCallOutput { + id: Some(id), call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -1705,24 +1758,14 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; - assert_eq!( - session.state.lock().await.clone_history().raw_items(), - expected.as_slice() - ); + assert_eq!(history.raw_items(), expected.as_slice()); } #[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(), @@ -1736,7 +1779,7 @@ async fn resize_all_images_prepares_resumed_history_before_installing_it() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session @@ -1761,7 +1804,7 @@ async fn resize_all_images_prepares_resumed_history_before_installing_it() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -1850,6 +1893,7 @@ fn session_meta_item( ) -> RolloutItem { RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version, ..SessionMeta::default() @@ -2107,10 +2151,12 @@ async fn record_token_usage_info_notifies_extension_contributors() { session .record_token_usage_info(&turn_context, Some(&first_usage)) - .await; + .await + .expect("first usage should be recorded"); session .record_token_usage_info(&turn_context, Some(&second_usage)) - .await; + .await + .expect("second usage should be recorded"); let mut expected_total_usage = first_usage.clone(); expected_total_usage.add_assign(&second_usage); @@ -2675,7 +2721,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] - cwd: turn_context.cwd.to_path_buf(), + cwd: turn_context.cwd.clone(), workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -2689,6 +2735,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -3012,6 +3059,9 @@ 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(&[ RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -3053,7 +3103,10 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::Compacted(CompactedItem { message: "summary after compaction".to_string(), replacement_history: Some(compacted_history.clone()), - window_id: Some(7), + 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 { turn_id: compact_turn_id, @@ -3103,7 +3156,14 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .await; { let mut state = sess.state.lock().await; - state.set_auto_compact_window_id(/*window_id*/ 99); + 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; @@ -3112,6 +3172,14 @@ 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_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")); } @@ -3300,6 +3368,7 @@ async fn set_rate_limits_retains_previous_credits() { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -3406,6 +3475,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -3532,7 +3602,7 @@ async fn includes_timed_out_message() { }; let (_, turn_context) = make_session_and_context().await; - let out = format_exec_output_str(&exec, turn_context.truncation_policy); + let out = format_exec_output_str(&exec, turn_context.model_info.truncation_policy.into()); assert_eq!( out, @@ -3571,10 +3641,6 @@ async fn turn_context_with_model_updates_model_fields() { updated.config.model_reasoning_effort, Some(ReasoningEffortConfig::Medium) ); - assert_eq!( - updated.truncation_policy, - expected_model_info.truncation_policy.into() - ); } #[test] @@ -3678,6 +3744,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, @@ -3937,6 +4004,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -4056,6 +4124,7 @@ async fn resolved_environments_for_configuration( default_user_shell(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, ); turn_environments.update_selections(session_configuration.environment_selections()); (environment_manager, turn_environments.snapshot().await) @@ -4439,15 +4508,16 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { .default_environment() .map(|environment| environment.get_filesystem()) .unwrap_or_else(|| std::sync::Arc::clone(&codex_exec_server::LOCAL_FS)); - let parent_outcome = session + let parent_snapshot = session .services - .skills_manager - .skills_for_cwd( + .skills_service + .snapshot_for_cwd( &crate::skills_load_input_from_config(&parent_config, Vec::new()), /*force_reload*/ true, Some(Arc::clone(&skill_fs)), ) .await; + let parent_outcome = parent_snapshot.outcome(); let parent_skill = parent_outcome .skills .iter() @@ -4493,13 +4563,18 @@ enabled = false .await; let child_skill = child_turn .turn_skills - .outcome + .snapshot + .outcome() .skills .iter() .find(|skill| skill.name == "demo-skill") .expect("demo skill should be discovered"); assert_eq!( - child_turn.turn_skills.outcome.is_skill_enabled(child_skill), + child_turn + .turn_skills + .snapshot + .outcome() + .is_skill_enabled(child_skill), false ); } @@ -4796,6 +4871,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -4830,7 +4906,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -4847,11 +4923,12 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { agent_status_tx, InitialHistory::New, SessionSource::Exec, - skills_manager, + skills_service, plugins_manager, mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -4862,6 +4939,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + /*external_time_provider*/ None, Some(config.multi_agent_version_from_features()), ) .await; @@ -4906,6 +4984,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -4957,6 +5036,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { default_user_shell(), ShellSnapshot::disabled(), resolved_environments, + /*non_blocking_snapshots*/ false, )); let environment = Arc::clone( &resolved_turn_environments @@ -4966,7 +5046,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -5006,7 +5086,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { guardian_rejections: Mutex::new(std::collections::HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), @@ -5015,6 +5095,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), @@ -5027,6 +5108,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*state_db*/ None, )), attestation_provider: None, + time_provider: Arc::new(crate::current_time::SystemTimeProvider), model_client: ModelClient::new( Some(auth_manager.clone()), thread_id, @@ -5036,6 +5118,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), @@ -5043,20 +5126,23 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { turn_environments: Arc::clone(&turn_environments), }; + let plugins_input = per_turn_config.plugins_config_input(); let plugin_outcome = services .plugins_manager - .plugins_for_config(&per_turn_config.plugins_config_input()) + .plugins_for_config(&plugins_input) .await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); + let plugin_skill_snapshots = services + .plugins_manager + .plugin_skill_snapshots_for_config(&plugins_input); let skills_input = - crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); + crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let skill_fs = environment.get_filesystem(); - let skills_outcome = Arc::new( - services - .skills_manager - .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) - .await, - ); + let skills_snapshot = services + .skills_service + .snapshot_for_config(&skills_input, Some(Arc::clone(&skill_fs))) + .await; let turn_context = Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -5075,7 +5161,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { resolved_turn_environments, session_configuration.cwd().clone(), "turn_id".to_string(), - skills_outcome, + skills_snapshot, ); let session = Session { @@ -5145,6 +5231,7 @@ async fn make_session_with_config_and_rx( let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -5179,7 +5266,7 @@ async fn make_session_with_config_and_rx( let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -5197,11 +5284,12 @@ async fn make_session_with_config_and_rx( agent_status_tx, InitialHistory::New, SessionSource::Exec, - skills_manager, + skills_service, plugins_manager, mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -5212,6 +5300,7 @@ async fn make_session_with_config_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + /*external_time_provider*/ None, Some(config.multi_agent_version_from_features()), ) .await?; @@ -5249,6 +5338,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -5283,7 +5373,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -5301,11 +5391,12 @@ async fn make_session_with_history_source_and_agent_control_and_rx( agent_status_tx, initial_history, session_source, - skills_manager, + skills_service, plugins_manager, mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, agent_control, environment_manager, /*inherited_environments*/ None, @@ -5323,6 +5414,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + /*external_time_provider*/ None, Some(config.multi_agent_version_from_features()), ) .await?; @@ -5357,7 +5449,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(); @@ -5371,11 +5463,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: 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"); @@ -5700,7 +5800,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir turn_context_mut.environments.turn_environments[0] = TurnEnvironment::new( "remote".to_string(), current_environment.environment, - environment_cwd.clone(), + PathUri::from_abs_path(&environment_cwd), current_environment.shell, ); @@ -6319,13 +6419,14 @@ async fn primary_environment_uses_first_turn_environment() { let first_environment = turn_context.environments.turn_environments[0].clone(); #[allow(deprecated)] let second_cwd = turn_context.cwd.join("second"); + let second_cwd_uri = codex_utils_path_uri::PathUri::from_abs_path(&second_cwd); turn_context .environments .turn_environments .push(TurnEnvironment::new( "second".to_string(), Arc::clone(&first_environment.environment), - second_cwd.clone(), + second_cwd_uri.clone(), /*shell*/ None, )); @@ -6345,12 +6446,12 @@ async fn primary_environment_uses_first_turn_environment() { .find(|environment| environment.environment_id == "second") .expect("second environment") .cwd(), - &second_cwd + &second_cwd_uri ); assert_eq!(turn_context.environments.turn_environments.len(), 2); assert_eq!( turn_context.environments.turn_environments[1].cwd(), - &second_cwd + &second_cwd_uri ); } @@ -6401,13 +6502,13 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { _ctx: Arc, _input: Vec, _cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let mut trace = self .captured_trace .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); *trace = current_span_w3c_trace_context(); - None + Ok(None) } } @@ -6493,6 +6594,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, @@ -6952,6 +7054,7 @@ where let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -7002,6 +7105,7 @@ where default_user_shell(), ShellSnapshot::disabled(), resolved_turn_environments.clone(), + /*non_blocking_snapshots*/ false, )); let environment = Arc::clone( &resolved_turn_environments @@ -7011,7 +7115,7 @@ where ); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -7051,7 +7155,7 @@ where guardian_rejections: Mutex::new(std::collections::HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), @@ -7060,6 +7164,7 @@ where ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), @@ -7072,6 +7177,7 @@ where state_db, )), attestation_provider: None, + time_provider: Arc::new(crate::current_time::SystemTimeProvider), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), thread_id, @@ -7081,6 +7187,7 @@ where config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), @@ -7088,20 +7195,23 @@ where turn_environments: Arc::clone(&turn_environments), }; + let plugins_input = per_turn_config.plugins_config_input(); let plugin_outcome = services .plugins_manager - .plugins_for_config(&per_turn_config.plugins_config_input()) + .plugins_for_config(&plugins_input) .await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); + let plugin_skill_snapshots = services + .plugins_manager + .plugin_skill_snapshots_for_config(&plugins_input); let skills_input = - crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); + crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let skill_fs = environment.get_filesystem(); - let skills_outcome = Arc::new( - services - .skills_manager - .skills_for_config(&skills_input, Some(Arc::clone(&skill_fs))) - .await, - ); + let skills_snapshot = services + .skills_service + .snapshot_for_config(&skills_input, Some(Arc::clone(&skill_fs))) + .await; let turn_context = Arc::new(Session::make_turn_context( thread_id, SessionId::from(thread_id), @@ -7120,7 +7230,7 @@ where resolved_turn_environments, session_configuration.cwd().clone(), "turn_id".to_string(), - skills_outcome, + skills_snapshot, )); let session = Arc::new(Session { @@ -7541,9 +7651,13 @@ async fn make_multi_agent_v2_usage_hint_test_session( struct PromptExtensionTestContributor; struct PromptExtensionTestState; +struct TurnContextExtensionTestContributor; +struct TurnContextExtensionTestState { + expected_model_context_window: Option, +} impl codex_extension_api::ContextContributor for PromptExtensionTestContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, thread_store: &'a codex_extension_api::ExtensionData, @@ -7572,6 +7686,31 @@ fn prompt_extension_test_registry() Arc::new(builder.build()) } +impl codex_extension_api::ContextContributor for TurnContextExtensionTestContributor { + fn contribute_turn_context<'a>( + &'a self, + input: codex_extension_api::TurnContextContributionInput<'a>, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + let Some(state) = input.turn_store.get::() else { + return Vec::new(); + }; + (input.model_context_window == state.expected_model_context_window + && input.model_context_window.is_some() + && !input.turn_id.is_empty()) + .then(|| { + codex_extension_api::PromptFragment::developer_policy( + "turn context extension enabled", + ) + }) + .into_iter() + .collect() + }) + } +} + #[tokio::test] async fn build_initial_context_includes_prompt_fragments_from_extensions() { let (mut session, turn_context) = make_session_and_context().await; @@ -7593,6 +7732,67 @@ async fn build_initial_context_includes_prompt_fragments_from_extensions() { ); } +#[tokio::test] +async fn build_initial_context_includes_turn_context_fragments_from_extensions() { + let (mut session, mut turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); + session.services.extensions = Arc::new(builder.build()); + turn_context.model_info.context_window = Some(100); + turn_context.model_info.effective_context_window_percent = 50; + turn_context + .extension_data + .insert(TurnContextExtensionTestState { + expected_model_context_window: Some(50), + }); + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_messages = developer_message_texts(&initial_context); + + assert!( + developer_messages + .iter() + .flatten() + .any(|text| *text == "turn context extension enabled"), + "expected turn context extension developer text, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn record_context_updates_includes_turn_context_fragments_on_steady_state_turns() { + let (mut session, mut turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); + session.services.extensions = Arc::new(builder.build()); + turn_context.model_info.context_window = Some(200); + turn_context.model_info.effective_context_window_percent = 25; + turn_context + .extension_data + .insert(TurnContextExtensionTestState { + expected_model_context_window: Some(50), + }); + let mut previous_context_item = turn_context.to_turn_context_item(); + previous_context_item.turn_id = Some("previous-turn-id".to_string()); + { + let mut state = session.state.lock().await; + state.set_reference_context_item(Some(previous_context_item)); + } + + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + + let history = session.clone_history().await; + let developer_messages = developer_message_texts(history.raw_items()); + assert!( + developer_messages + .iter() + .flatten() + .any(|text| *text == "turn context extension enabled"), + "expected steady-state turn context extension developer text, got {developer_messages:?}" + ); +} + #[tokio::test] async fn build_initial_context_omits_prompt_fragments_without_extension_state() { let (mut session, turn_context) = make_session_and_context().await; @@ -7690,15 +7890,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; @@ -7723,11 +7922,11 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist session .replace_history( vec![ResponseItem::ImageGenerationCall { - id: "ig-test".to_string(), + id: Some("ig-test".to_string()), 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, ) @@ -7787,7 +7986,7 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget() }, ]; turn_context.model_info.context_window = Some(100); - turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome)); + turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome))); let initial_context = session.build_initial_context(&turn_context).await; let developer_texts = developer_input_texts(&initial_context); @@ -7938,7 +8137,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil }, ]; turn_context.model_info.context_window = Some(100); - turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome)); + turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome))); let _ = session.build_initial_context(&turn_context).await; let warning_event = timeout(Duration::from_secs(1), rx.recv()) @@ -7976,11 +8175,11 @@ async fn handle_output_item_done_records_image_save_history_message() { ); let _ = std::fs::remove_file(&expected_saved_path); let item = ResponseItem::ImageGenerationCall { - id: call_id.to_string(), + id: Some(call_id.to_string()), 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 { @@ -8033,11 +8232,11 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { ); let _ = std::fs::remove_file(&expected_saved_path); let item = ResponseItem::ImageGenerationCall { - id: call_id.to_string(), + id: Some(call_id.to_string()), 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 { @@ -8122,15 +8321,20 @@ fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSy } #[tokio::test] -async fn turn_context_item_uses_turn_context_comp_hash_snapshot() { +async fn turn_context_item_stores_local_cwd() { let (_session, mut turn_context) = make_session_and_context().await; - turn_context.comp_hash = Some("turn-context-hash".to_string()); - turn_context.model_info.comp_hash = Some("model-info-hash".to_string()); - - assert_eq!( - turn_context.to_turn_context_item().comp_hash.as_deref(), - Some("turn-context-hash") + let environment = turn_context.environments.turn_environments[0].clone(); + let cwd = PathUri::parse("file:///C:/windows").expect("Windows cwd URI"); + turn_context.environments.turn_environments[0] = TurnEnvironment::new( + "remote".to_string(), + environment.environment, + cwd, + environment.shell, ); + + #[allow(deprecated)] + let local_cwd = turn_context.cwd.clone(); + assert_eq!(turn_context.to_turn_context_item().cwd, local_cwd); } #[tokio::test] @@ -8198,7 +8402,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)) @@ -8449,7 +8653,7 @@ async fn workflow_output_records_assistant_message_for_next_context() { text: markdown.clone(), }], phase: Some(MessagePhase::FinalAnswer), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let recorded = record_workflow_output( @@ -8578,8 +8782,8 @@ impl SessionTask for CompletingTask { _ctx: Arc, _input: Vec, _cancellation_token: CancellationToken, - ) -> Option { - None + ) -> SessionTaskResult { + Ok(None) } } @@ -8604,10 +8808,10 @@ impl SessionTask for NeverEndingTask { _ctx: Arc, _input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { if self.listen_to_cancellation_token { cancellation_token.cancelled().await; - return None; + return Ok(None); } loop { sleep(Duration::from_secs(60)).await; @@ -8633,14 +8837,14 @@ impl SessionTask for GuardianDeniedApprovalTask { ctx: Arc, _input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let session = session.clone_session(); for _ in 0..3 { crate::guardian::record_guardian_denial_for_test(&session, &ctx, &ctx.sub_id).await; } cancellation_token.cancelled().await; - None + Ok(None) } } @@ -8863,7 +9067,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() .await .expect("steer pending input into active turn"); - sess.on_task_finished(Arc::clone(&tc), /*last_agent_message*/ None) + sess.on_task_finished(Arc::clone(&tc), /*task_result*/ Ok(None)) .await; let history = sess.clone_history().await; @@ -8874,7 +9078,7 @@ 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), @@ -9307,7 +9511,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; @@ -9566,7 +9770,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), @@ -9680,9 +9884,9 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { let router = ToolRouter::from_turn_context( &turn_context, crate::tools::router::ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools, mcp_tools: Some(tools), - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, @@ -9694,7 +9898,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()) @@ -9769,7 +9973,7 @@ async fn sample_rollout( } live_history.record_items( initial_context.iter(), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); let user1 = ResponseItem::Message { @@ -9779,11 +9983,11 @@ 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), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user1.clone())); @@ -9794,11 +9998,11 @@ 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), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant1.clone())); @@ -9809,10 +10013,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_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), replacement_history: None, - window_id: None, + window_number: Some(window_number), + 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 { @@ -9822,11 +10030,11 @@ 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), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user2.clone())); @@ -9837,11 +10045,11 @@ 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), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant2.clone())); @@ -9852,10 +10060,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_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), replacement_history: None, - window_id: None, + window_number: Some(window_number), + 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 { @@ -9865,11 +10077,11 @@ 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), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user3)); @@ -9880,11 +10092,11 @@ 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), - reconstruction_turn.truncation_policy, + reconstruction_turn.model_info.truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant3)); @@ -10026,7 +10238,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 e958b39e1f7d..e44b12ef720a 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -95,11 +95,11 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw + let mut config = (*turn_context_raw.config).clone(); + config .features .enable(Feature::GuardianApproval) .expect("test setup should allow enabling guardian approvals"); - let mut config = (*turn_context_raw.config).clone(); config.approvals_reviewer = ApprovalsReviewer::AutoReview; config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); @@ -183,11 +183,11 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw + let mut config = (*turn_context_raw.config).clone(); + config .features .enable(Feature::GuardianApproval) .expect("test setup should allow enabling guardian approvals"); - let mut config = (*turn_context_raw.config).clone(); config.approvals_reviewer = ApprovalsReviewer::AutoReview; config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); @@ -292,21 +292,21 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli .await; let (mut session, mut turn_context_raw) = make_session_and_context().await; - turn_context_raw.codex_linux_sandbox_exe = codex_linux_sandbox_exe_or_skip!(); turn_context_raw .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw - .features - .enable(Feature::GuardianApproval) - .expect("test setup should allow enabling guardian approvals"); session .features .enable(Feature::ExecPermissionApprovals) .expect("test setup should allow enabling request permissions"); turn_context_raw.permission_profile = codex_protocol::models::PermissionProfile::Disabled; let mut config = (*turn_context_raw.config).clone(); + config.codex_linux_sandbox_exe = codex_linux_sandbox_exe_or_skip!(); + config + .features + .enable(Feature::GuardianApproval) + .expect("test setup should allow enabling guardian approvals"); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); let models_manager = models_manager_with_provider( @@ -467,7 +467,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic .approval_policy .set(AskForApproval::OnRequest) .expect("test setup should allow updating approval policy"); - turn_context_raw + Arc::make_mut(&mut turn_context_raw.config) .features .enable(Feature::GuardianApproval) .expect("test setup should allow enabling guardian approvals"); @@ -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, @@ -694,7 +694,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config.model_provider.clone(), ); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); - let skills_manager = Arc::new(SkillsManager::new( + let skills_service = Arc::new(SkillsService::new( config.codex_home.clone(), /*bundled_skills_enabled*/ true, )); @@ -711,7 +711,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { auth_manager, models_manager, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: codex_extension_api::empty_extension_registry(), @@ -732,10 +732,13 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { parent_trace: None, environment_selections: Vec::new(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: false, analytics_events_client: None, thread_store, attestation_provider: None, + external_time_provider: None, inherited_multi_agent_version: None, + initial_multi_agent_mode: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/session/time_reminder.rs b/codex-rs/core/src/session/time_reminder.rs new file mode 100644 index 000000000000..058132a8956c --- /dev/null +++ b/codex-rs/core/src/session/time_reminder.rs @@ -0,0 +1,61 @@ +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; + +use super::session::Session; +use super::turn_context::TurnContext; +use crate::context::ContextualUserFragment; + +#[derive(Default)] +pub(crate) struct CurrentTimeReminderState { + model_requests_since_delivery: u64, + last_window_id: Option, +} + +impl CurrentTimeReminderState { + fn take_reminder_due(&mut self, window_id: &str, interval: u64) -> bool { + self.model_requests_since_delivery = self.model_requests_since_delivery.saturating_add(1); + let reminder_is_due = self.last_window_id.as_deref() != Some(window_id) + || self.model_requests_since_delivery >= interval; + + if reminder_is_due { + self.model_requests_since_delivery = 0; + self.last_window_id = Some(window_id.to_string()); + } + + reminder_is_due + } +} + +pub(super) async fn maybe_record_current_time_reminder( + sess: &Session, + turn_context: &TurnContext, + window_id: &str, +) -> CodexResult<()> { + let Some(config) = turn_context.config.current_time_reminder else { + return Ok(()); + }; + + let reminder_is_due = { + let mut state = sess.state.lock().await; + state + .current_time_reminder + .take_reminder_due(window_id, config.reminder_interval_model_requests) + }; + if !reminder_is_due { + return Ok(()); + } + + let current_time = sess + .services + .time_provider + .current_time(sess.thread_id) + .await + .map_err(|err| CodexErr::Fatal(format!("failed to read current time: {err:#}")))?; + + let response_item = + ContextualUserFragment::into(crate::context::CurrentTimeReminder::new(current_time)); + sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + .await; + + Ok(()) +} diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index b8f9f82fbc0c..17f27c106bec 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.features.enabled(Feature::TokenBudget) { + 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 { - 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 { + let reminder_due = { + let mut state = sess.state.lock().await; + state.claim_token_budget_reminder() + }; + if !reminder_due { 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 8762e708c256..69797905dbfb 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -46,6 +46,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; @@ -62,6 +63,8 @@ use crate::tools::context::SharedTurnDiffTracker; use crate::tools::parallel::ToolCallRuntime; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolRouterParams; +use crate::tools::router::ToolSuggestCandidates; +use crate::tools::router::ToolSuggestPresentation; use crate::tools::router::extension_tool_executors; use crate::tools::spec_plan::search_tool_enabled; use crate::tools::spec_plan::tool_suggest_enabled; @@ -75,6 +78,7 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_core_plugins::RecommendedPluginCandidatesInput; use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; @@ -102,6 +106,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::TokenUsage; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::protocol::WarningEvent; @@ -147,7 +152,7 @@ pub(crate) async fn run_turn( input: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, -) -> Option { +) -> CodexResult> { let model_client = model_client_for_config( &turn_context.config, &sess.services.model_client, @@ -159,32 +164,38 @@ pub(crate) async fn run_turn( // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { + if matches!(err, CodexErr::TurnAborted) { + return Err(err); + } let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; error!("Failed to run pre-sampling compact"); - return None; + return Ok(None); } sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) .await; - let (injection_items, explicitly_enabled_connectors) = - build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?; + let Some((injection_items, explicitly_enabled_connectors)) = + build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await + else { + return Ok(None); + }; if run_pending_session_start_hooks(&sess, &turn_context).await { - return None; + return Ok(None); } let mut can_drain_pending_input = input.is_empty(); if run_hooks_and_record_inputs(&sess, &turn_context, &input).await { - return None; + return Ok(None); } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; sess.set_previous_turn_settings(Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), - comp_hash: turn_context.comp_hash.clone(), + comp_hash: turn_context.model_info.comp_hash.clone(), realtime_active: Some(turn_context.realtime_active), })) .await; @@ -225,34 +236,65 @@ pub(crate) async fn run_turn( break; } - // Construct the input that we will send to the model. - let sampling_request_input: Vec = async { - sess.clone_history() - .await - .for_prompt(&turn_context.model_info.input_modalities) - } - .instrument(trace_span!("run_turn.prepare_sampling_request_input")) - .await; - let window_id = sess.current_window_id().await; - let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( - sess.installation_id.clone(), - window_id, - CodexResponsesRequestKind::Turn, - ); - let tokens_before_sampling = sess.get_total_token_usage().await; - match run_sampling_request( - Arc::clone(&sess), - Arc::clone(&turn_context), - Arc::clone(&turn_extension_data), - Arc::clone(&turn_diff_tracker), - &mut client_session, - &responses_metadata, - sampling_request_input, - cancellation_token.child_token(), + super::rollout_budget::maybe_record_reminder( + sess.as_ref(), + turn_context.as_ref(), + &window_id, ) - .await - { + .await; + + let sampling_request_result: CodexResult<_> = async { + super::time_reminder::maybe_record_current_time_reminder( + sess.as_ref(), + turn_context.as_ref(), + &window_id, + ) + .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() + .await + .for_prompt(&turn_context.model_info.input_modalities) + } + .instrument(trace_span!("run_turn.prepare_sampling_request_input")) + .await; + + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + sess.installation_id.clone(), + window_id, + CodexResponsesRequestKind::Turn, + ); + run_sampling_request( + Arc::clone(&sess), + Arc::clone(&turn_context), + Arc::clone(&turn_extension_data), + Arc::clone(&turn_diff_tracker), + &mut client_session, + &responses_metadata, + sampling_request_input, + cancellation_token.child_token(), + ) + .await + } + .await; + match sampling_request_result { Ok((sampling_request_output, sampling_request_input)) => { let SamplingRequestResult { needs_follow_up: model_needs_follow_up, @@ -291,11 +333,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; @@ -309,7 +360,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, @@ -320,10 +377,13 @@ pub(crate) async fn run_turn( ) .await { + if matches!(err, CodexErr::TurnAborted) { + return Err(err); + } let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - return None; + return Ok(None); } can_drain_pending_input = !model_needs_follow_up; continue; @@ -370,15 +430,14 @@ pub(crate) async fn run_turn( ) .await { - return None; + return Ok(None); } break; } continue; } - Err(CodexErr::TurnAborted) => { - // Aborted turn is reported via a different event. - break; + Err(err @ CodexErr::TurnAborted) => { + return Err(err); } Err(codex_error @ CodexErr::InvalidImageRequest()) => { { @@ -417,20 +476,23 @@ pub(crate) async fn run_turn( } } - last_agent_message + Ok(last_agent_message) } #[instrument(level = "trace", skip_all)] async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> { let mut display_roots = Vec::new(); for turn_environment in &turn_context.environments.turn_environments { - let root = get_git_repo_root_with_fs( - turn_environment.environment.get_filesystem().as_ref(), - turn_environment.cwd(), - ) - .await - .unwrap_or_else(|| turn_environment.cwd().clone()) - .into_path_buf(); + // TODO(anp): Migrate git-root discovery and diff display roots to PathUri so foreign + // environment roots can participate without host-native conversion. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + continue; + }; + let root = + get_git_repo_root_with_fs(turn_environment.environment.get_filesystem().as_ref(), &cwd) + .await + .unwrap_or(cwd) + .into_path_buf(); display_roots.push((turn_environment.environment_id.clone(), root)); } display_roots @@ -532,7 +594,7 @@ async fn build_skills_and_plugins( } else { Vec::new() }; - let skills_outcome = turn_context.turn_skills.outcome.as_ref(); + let skills_outcome = turn_context.turn_skills.snapshot.outcome(); let connector_slug_counts = build_connector_slug_counts(&available_connectors); let extension_injection_items = build_extension_turn_input_items(sess, turn_context, &user_input, cancellation_token) @@ -604,13 +666,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 { @@ -628,6 +693,11 @@ async fn build_skills_and_plugins( Some((injection_items, explicitly_enabled_connectors)) } +#[tracing::instrument( + level = "trace", + skip_all, + fields(user_input_count = user_input.len()) +)] async fn build_extension_turn_input_items( sess: &Arc, turn_context: &TurnContext, @@ -644,10 +714,14 @@ async fn build_extension_turn_input_items( .turn_environments .iter() .enumerate() - .map(|(index, environment)| TurnInputEnvironment { - environment_id: environment.environment_id.clone(), - cwd: environment.cwd().as_path().to_path_buf(), - is_primary: index == 0, + .filter_map(|(index, environment)| { + // TODO(anp): Migrate extension turn-input environments to PathUri so foreign cwd + // values are not omitted from extension context. + Some(TurnInputEnvironment { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd().to_abs_path().ok()?.into_path_buf(), + is_primary: index == 0, + }) }) .collect::>(); @@ -679,6 +753,11 @@ async fn build_extension_turn_input_items( Some(items) } +#[tracing::instrument( + level = "trace", + skip_all, + fields(input_count = input.len()) +)] async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, @@ -801,6 +880,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. @@ -840,7 +927,7 @@ async fn maybe_run_previous_model_inline_compact( }; let should_compact_for_comp_hash_change = comp_hash_changed( previous_turn_settings.comp_hash.as_deref(), - turn_context.comp_hash.as_deref(), + turn_context.model_info.comp_hash.as_deref(), ); let previous_model_turn_context = Arc::new( turn_context @@ -913,7 +1000,11 @@ async fn run_auto_compact( phase: CompactionPhase, ) -> CodexResult<()> { if should_use_remote_compact_task(turn_context.provider.info()) { - if turn_context.features.enabled(Feature::RemoteCompactionV2) { + if turn_context + .config + .features + .enabled(Feature::RemoteCompactionV2) + { emit_compact_metric( &sess.services.session_telemetry, "remote_v2", @@ -1025,7 +1116,6 @@ pub(crate) fn build_prompt( tools: router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, output_schema: turn_context.final_output_json_schema.clone(), output_schema_strict: !crate::guardian::is_guardian_reviewer_source( &turn_context.session_source, @@ -1212,46 +1302,78 @@ pub(crate) async fn built_tools( } else { None }; - let auth = sess.services.auth_manager.auth().await; - let loaded_plugin_app_connector_ids = loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0) - .collect::>(); - let discoverable_tools = async { - if apps_enabled && tool_suggest_enabled(turn_context) { - if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { - match connectors::list_tool_suggest_discoverable_tools_with_auth( - &turn_context.config, - sess.services.plugins_manager.as_ref(), - auth.as_ref(), - accessible_connectors.as_slice(), - &loaded_plugin_app_connector_ids, - ) - .await - .map(|discoverable_tools| { - filter_request_plugin_install_discoverable_tools_for_client( - discoverable_tools, - turn_context.app_server_client_name.as_deref(), - ) - }) { - Ok(discoverable_tools) if discoverable_tools.is_empty() => None, - Ok(discoverable_tools) => Some(discoverable_tools), - Err(err) => { - warn!("failed to load discoverable tool suggestions: {err:#}"); + let tool_suggest_is_enabled = tool_suggest_enabled(turn_context); + let auth = if tool_suggest_is_enabled { + sess.services.auth_manager.auth().await + } else { + None + }; + let endpoint_recommended_plugin_candidates = if tool_suggest_is_enabled { + let plugins_config = turn_context.config.plugins_config_input(); + sess.services + .plugins_manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &plugins_config, + loaded_plugins: &loaded_plugins, + auth: auth.as_ref(), + disabled_tools: &turn_context.config.tool_suggest.disabled_tools, + app_server_client_name: turn_context.app_server_client_name.as_deref(), + }) + .await + } else { + None + }; + let tool_suggest_candidates = + if let Some(recommended_plugin_candidates) = endpoint_recommended_plugin_candidates { + Some(ToolSuggestCandidates { + tools: recommended_plugin_candidates, + presentation: ToolSuggestPresentation::RecommendationContext, + }) + } else { + let loaded_plugin_app_connector_ids = loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0) + .collect::>(); + async { + if apps_enabled && tool_suggest_is_enabled { + if let Some(accessible_connectors) = + accessible_connectors_with_enabled_state.as_ref() + { + match connectors::list_tool_suggest_discoverable_tools_with_auth( + &turn_context.config, + sess.services.plugins_manager.as_ref(), + auth.as_ref(), + accessible_connectors.as_slice(), + &loaded_plugin_app_connector_ids, + ) + .await + .map(|discoverable_tools| { + filter_request_plugin_install_discoverable_tools_for_client( + discoverable_tools, + turn_context.app_server_client_name.as_deref(), + ) + }) { + Ok(discoverable_tools) if discoverable_tools.is_empty() => None, + Ok(discoverable_tools) => Some(ToolSuggestCandidates { + tools: discoverable_tools, + presentation: ToolSuggestPresentation::ListTool, + }), + Err(err) => { + warn!("failed to load discoverable tool suggestions: {err:#}"); + None + } + } + } else { None } + } else { + None } - } else { - None } - } else { - None - } - } - .instrument(trace_span!("built_tools.load_discoverable_tools")) - .await; - + .instrument(trace_span!("built_tools.load_discoverable_tools")) + .await + }; let mcp_tool_exposure = build_mcp_tool_exposure( &all_mcp_tools, connectors.as_deref(), @@ -1265,7 +1387,7 @@ pub(crate) async fn built_tools( ToolRouterParams { mcp_tools, deferred_mcp_tools, - discoverable_tools, + tool_suggest_candidates, extension_tool_executors: extension_tool_executors(sess), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, @@ -1493,11 +1615,11 @@ fn agent_message_text(item: &codex_protocol::items::AgentMessageItem) -> String .collect() } -pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { +pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option)> { match msg { - EventMsg::AgentMessage(event) => Some(event.message.clone()), + EventMsg::AgentMessage(event) => Some((event.message.clone(), event.phase.clone())), EventMsg::ItemCompleted(event) => match &event.item { - TurnItem::AgentMessage(item) => Some(agent_message_text(item)), + TurnItem::AgentMessage(item) => Some((agent_message_text(item), item.phase.clone())), _ => None, }, EventMsg::Error(_) @@ -1510,6 +1632,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::TurnModerationMetadata(_) + | EventMsg::SafetyBuffering(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -2219,6 +2342,17 @@ 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, + }), + ) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } @@ -2247,7 +2381,8 @@ async fn try_run_sampling_request( if let Some(token_usage) = token_usage.as_ref() { client_session.record_account_pool_cache_hint(token_usage); } - sess.record_token_usage_info(&turn_context, token_usage.as_ref()) + let budget_result = sess + .record_token_usage_info(&turn_context, token_usage.as_ref()) .await; let accounting_usage = token_usage.clone().unwrap_or_default(); record_model_router_request_usage_for_config( @@ -2260,6 +2395,9 @@ async fn try_run_sampling_request( model_router_usage_recorded = true; should_emit_token_count = true; should_emit_turn_diff = true; + if let Err(err) = budget_result { + break Err(err); + } if let Some(false) = end_turn { needs_follow_up = true; } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 63a561967d56..131cabd7210b 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,13 +1,10 @@ use super::*; -use crate::SkillLoadOutcome; use crate::agents_md::LoadedAgentsMd; -use crate::config::GhostSnapshotConfig; use crate::environment_selection::TurnEnvironmentSnapshot; -use crate::path_utils; use crate::shell_snapshot::ShellSnapshotFile; use codex_config::types::ArtifactStyle; use codex_config::types::ResponseStyle; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_file_system::FileSystemSandboxContext; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; @@ -16,11 +13,9 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::Verbosity; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; @@ -35,14 +30,14 @@ use tracing::instrument; #[derive(Clone, Debug)] pub(crate) struct TurnSkillsContext { - pub(crate) outcome: Arc, + pub(crate) snapshot: HostSkillsSnapshot, pub(crate) implicit_invocation_seen_skills: Arc>>, } impl TurnSkillsContext { - pub(crate) fn new(outcome: Arc) -> Self { + pub(crate) fn new(snapshot: HostSkillsSnapshot) -> Self { Self { - outcome, + snapshot, implicit_invocation_seen_skills: Arc::new(Mutex::new(HashSet::new())), } } @@ -54,13 +49,7 @@ pub(crate) type ShellSnapshotTask = Shared, - // Keep both representations together while cwd consumers migrate to URI semantics. Keeping - // them synchronized means neither representation can be exposed through a mutable reference; - // updates must rebuild the validated pair through `TurnEnvironment::new`. Once - // `TurnEnvironment::cwd` itself becomes a `PathUri`, convert only at native filesystem and - // process-launch boundaries and remove this paired migration state. - cwd: AbsolutePathBuf, - cwd_uri: PathUri, + cwd: PathUri, pub(crate) shell: Option, pub(crate) shell_snapshot: ShellSnapshotTask, } @@ -69,22 +58,20 @@ impl TurnEnvironment { pub(crate) fn new( environment_id: String, environment: Arc, - cwd: AbsolutePathBuf, + cwd: PathUri, shell: Option, ) -> Self { - let cwd_uri = PathUri::from_abs_path(&cwd); Self { environment_id, environment, cwd, - cwd_uri, shell, shell_snapshot: futures::future::ready(None).boxed().shared(), } } pub(crate) fn shell_snapshot(&self, cwd: &AbsolutePathBuf) -> Option { - if !path_utils::paths_match_after_normalization(self.cwd.as_path(), cwd.as_path()) { + if self.cwd != PathUri::from_abs_path(cwd) { return None; } self.shell_snapshot @@ -93,18 +80,14 @@ impl TurnEnvironment { .map(ShellSnapshotFile::path) } - pub(crate) fn cwd(&self) -> &AbsolutePathBuf { + pub(crate) fn cwd(&self) -> &PathUri { &self.cwd } - pub(crate) fn cwd_uri(&self) -> &PathUri { - &self.cwd_uri - } - pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), - cwd: self.cwd_uri.clone(), + cwd: self.cwd.clone(), } } } @@ -115,7 +98,6 @@ impl std::fmt::Debug for TurnEnvironment { .field("environment_id", &self.environment_id) .field("environment", &self.environment) .field("cwd", &self.cwd) - .field("cwd_uri", &self.cwd_uri) .field("shell", &self.shell) .finish_non_exhaustive() } @@ -130,15 +112,12 @@ pub struct TurnContext { pub config: Arc, pub(crate) auth_manager: Option>, pub(crate) model_info: ModelInfo, - pub(crate) comp_hash: Option, - pub(crate) tool_mode: ToolMode, pub(crate) session_telemetry: SessionTelemetry, pub(crate) provider: SharedModelProvider, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) parent_thread_id: Option, - pub(crate) thread_source: Option, pub(crate) environments: TurnEnvironmentSnapshot, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path @@ -149,29 +128,24 @@ pub struct TurnContext { pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, pub(crate) developer_instructions: Option, - pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, + pub(crate) multi_agent_mode: MultiAgentMode, pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, pub(crate) permission_profile: PermissionProfile, pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, - pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) available_models: Vec, pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, - pub features: ManagedFeatures, - pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, - pub(crate) codex_self_exe: Option, - pub(crate) codex_linux_sandbox_exe: Option, - pub(crate) truncation_policy: TruncationPolicy, pub(crate) dynamic_tools: Vec, pub(crate) turn_metadata_state: Arc, pub(crate) extension_data: Arc, pub(crate) turn_skills: TurnSkillsContext, pub(crate) turn_timing_state: Arc, + pub(crate) terminal_error: Arc>>, pub(crate) server_model_warning_emitted: AtomicBool, pub(crate) model_verification_emitted: AtomicBool, } @@ -272,7 +246,10 @@ impl TurnContext { .auth_manager .as_deref() .is_some_and(AuthManager::current_auth_uses_codex_backend); - self.features.apps_enabled_for_auth(uses_codex_backend) + self.config + .features + .apps_enabled_for_auth(uses_codex_backend) + && self.config.orchestrator_mcp_enabled } pub(crate) fn tool_environment_mode(&self) -> ToolEnvironmentMode { @@ -289,16 +266,6 @@ impl TurnContext { let model_info = models_manager .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; - let tool_mode = model_info.tool_mode.unwrap_or_else(|| { - if config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); - let truncation_policy = model_info.truncation_policy.into(); let supported_reasoning_levels = model_info .supported_reasoning_levels .iter() @@ -327,7 +294,6 @@ impl TurnContext { Some(reasoning_effort.clone()), /*developer_instructions*/ None, ); - let features = self.features.clone(); let available_models = models_manager .list_models(RefreshStrategy::OnlineIfUncached) .await; @@ -339,8 +305,6 @@ impl TurnContext { config: Arc::new(config), auth_manager: self.auth_manager.clone(), model_info: model_info.clone(), - comp_hash: model_info.comp_hash.clone(), - tool_mode, session_telemetry: self .session_telemetry .clone() @@ -350,7 +314,6 @@ impl TurnContext { reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), parent_thread_id: self.parent_thread_id, - thread_source: self.thread_source.clone(), environments: self.environments.clone(), #[allow(deprecated)] cwd: self.cwd.clone(), @@ -358,29 +321,24 @@ impl TurnContext { timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), developer_instructions: self.developer_instructions.clone(), - compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), collaboration_mode, + multi_agent_mode: self.multi_agent_mode, multi_agent_version: self.multi_agent_version, personality: self.personality, approval_policy: self.approval_policy.clone(), permission_profile: self.permission_profile.clone(), network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, - shell_environment_policy: self.shell_environment_policy.clone(), available_models, unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), - features, - ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), - codex_self_exe: self.codex_self_exe.clone(), - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), - truncation_policy, dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), extension_data: Arc::clone(&self.extension_data), turn_skills: self.turn_skills.clone(), turn_timing_state: Arc::clone(&self.turn_timing_state), + terminal_error: Arc::clone(&self.terminal_error), server_model_warning_emitted: AtomicBool::new( self.server_model_warning_emitted.load(Ordering::Relaxed), ), @@ -420,12 +378,18 @@ impl TurnContext { FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(cwd.clone()), + workspace_roots: self + .config + .effective_workspace_roots() + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config .permissions .windows_sandbox_private_desktop, - use_legacy_landlock: self.features.use_legacy_landlock(), + use_legacy_landlock: self.config.features.use_legacy_landlock(), } } @@ -445,18 +409,13 @@ impl TurnContext { .then_some(file_system_sandbox_policy) } - pub(crate) fn compact_prompt(&self) -> &str { - self.compact_prompt - .as_deref() - .unwrap_or(compact::SUMMARIZATION_PROMPT) - } - pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { let workspace_roots = self.config.effective_workspace_roots(); + #[allow(deprecated)] + let cwd = self.cwd.clone(); TurnContextItem { turn_id: Some(self.sub_id.clone()), - #[allow(deprecated)] - cwd: self.cwd.to_path_buf(), + cwd, workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -466,10 +425,15 @@ impl TurnContext { network: self.turn_context_network_item(), file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), model: self.model_info.slug.clone(), - comp_hash: self.comp_hash.clone(), + comp_hash: self.model_info.comp_hash.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), + multi_agent_mode: super::multi_agents::effective_multi_agent_mode( + self.multi_agent_version, + &self.session_source, + self.multi_agent_mode, + ), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort.clone(), summary: ReasoningSummaryConfig::Auto, @@ -582,7 +546,7 @@ impl Session { environments: TurnEnvironmentSnapshot, cwd: AbsolutePathBuf, sub_id: String, - skills_outcome: Arc, + skills_snapshot: HostSkillsSnapshot, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -605,15 +569,6 @@ impl Session { ); let mut per_turn_config = per_turn_config; - let tool_mode = model_info.tool_mode.unwrap_or_else(|| { - if per_turn_config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if per_turn_config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); per_turn_config.service_tier = get_service_tier( per_turn_config.service_tier, per_turn_config.features.enabled(Feature::FastMode), @@ -626,6 +581,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(), @@ -634,23 +590,20 @@ impl Session { )); let (current_date, timezone) = local_time_context(); let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); - extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome))); + extension_data.insert(skills_snapshot.clone()); TurnContext { sub_id, trace_id: current_span_trace_id(), realtime_active: false, - config: per_turn_config.clone(), + config: per_turn_config, auth_manager: auth_manager_for_context, - model_info: model_info.clone(), - comp_hash: model_info.comp_hash.clone(), - tool_mode, + model_info, session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, reasoning_summary, session_source, parent_thread_id: session_configuration.parent_thread_id, - thread_source: session_configuration.thread_source.clone(), environments, #[allow(deprecated)] cwd, @@ -658,32 +611,27 @@ impl Session { timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), - compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration .loaded_agents_md .as_ref() .map(LoadedAgentsMd::render), collaboration_mode: session_configuration.collaboration_mode.clone(), + multi_agent_mode: session_configuration.multi_agent_mode, multi_agent_version, personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), permission_profile: session_configuration.permission_profile(), network, windows_sandbox_level: session_configuration.windows_sandbox_level, - shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), available_models, unified_exec_shell_mode, - features: per_turn_config.features.clone(), - ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, - codex_self_exe: per_turn_config.codex_self_exe.clone(), - codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), - truncation_policy: model_info.truncation_policy.into(), dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, extension_data, - turn_skills: TurnSkillsContext::new(skills_outcome), + turn_skills: TurnSkillsContext::new(skills_snapshot), turn_timing_state: Arc::new(TurnTimingState::default()), + terminal_error: Arc::new(Mutex::new(None)), server_model_warning_emitted: AtomicBool::new(false), model_verification_emitted: AtomicBool::new(false), } @@ -797,9 +745,11 @@ impl Session { ) -> Arc { let turn_environments = self.services.turn_environments.snapshot().await; let primary_turn_environment = turn_environments.primary().cloned(); + // TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so + // a foreign primary environment does not fall back to the session's host cwd. let cwd = primary_turn_environment .as_ref() - .map(|turn_environment| turn_environment.cwd().clone()) + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { @@ -827,21 +777,26 @@ impl Session { .or(model_info.multi_agent_version) .unwrap_or_else(|| per_turn_config.multi_agent_version_from_features()), }; + let plugins_input = per_turn_config.plugins_config_input(); let plugin_outcome = self .services .plugins_manager - .plugins_for_config(&per_turn_config.plugins_config_input()) + .plugins_for_config(&plugins_input) .await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); + let plugin_skill_snapshots = self + .services + .plugins_manager + .plugin_skill_snapshots_for_config(&plugins_input); + let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let fs = primary_turn_environment .map(|turn_environment| turn_environment.environment.get_filesystem()); - let skills_outcome = Arc::new( - self.services - .skills_manager - .skills_for_config(&skills_input, fs) - .await, - ); + let skills_snapshot = self + .services + .skills_service + .snapshot_for_config(&skills_input, fs) + .await; let mut turn_context: TurnContext = Self::make_turn_context( self.thread_id(), self.session_id(), @@ -869,7 +824,7 @@ impl Session { turn_environments, cwd, sub_id, - skills_outcome, + skills_snapshot, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs index 7c9f003cb4a9..051799cf850a 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_prefix.rs b/codex-rs/core/src/session_prefix.rs index b50d1f9070a6..7d4a74a02e4b 100644 --- a/codex-rs/core/src/session_prefix.rs +++ b/codex-rs/core/src/session_prefix.rs @@ -1,8 +1,18 @@ +use codex_protocol::AgentPath; use codex_protocol::protocol::AgentStatus; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use crate::context::ContextualUserFragment; +use crate::context::InterAgentCompletionMessage; use crate::context::SubagentNotification; +const COMPLETION_MESSAGE_MAX_TOKENS: usize = 1_000; +const COMPLETION_MESSAGE_ENVELOPE_TOKEN_RESERVE: usize = 100; +const ERROR_MAX_TOKENS: usize = + COMPLETION_MESSAGE_MAX_TOKENS - COMPLETION_MESSAGE_ENVELOPE_TOKEN_RESERVE; +const ERROR_NEXT_ACTION: &str = "This agent's turn failed. If you still need this agent, use the available collaboration tools to give it another task."; + // Helpers for model-visible session state markers that are stored in user-role // messages but are not user intent. @@ -14,6 +24,29 @@ pub(crate) fn format_subagent_notification_message( SubagentNotification::new(agent_reference, status.clone()).render() } +pub(crate) fn format_inter_agent_completion_message( + task_name: AgentPath, + sender: AgentPath, + status: &AgentStatus, +) -> Option { + let payload = match status { + AgentStatus::Completed(Some(message)) => message.clone(), + AgentStatus::Completed(None) => String::new(), + AgentStatus::Errored(error) => { + let error = truncate_text(error, TruncationPolicy::Tokens(ERROR_MAX_TOKENS)); + format!("Agent errored: {error}\n\n{ERROR_NEXT_ACTION}") + } + AgentStatus::Shutdown => "Agent shut down.".to_string(), + AgentStatus::NotFound => "Agent was not found.".to_string(), + AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted => return None, + }; + Some(InterAgentCompletionMessage::new(task_name, sender, payload).render()) +} + +#[cfg(test)] +#[path = "session_prefix_tests.rs"] +mod tests; + pub(crate) fn format_subagent_context_line( agent_reference: &str, agent_nickname: Option<&str>, diff --git a/codex-rs/core/src/session_prefix_tests.rs b/codex-rs/core/src/session_prefix_tests.rs new file mode 100644 index 000000000000..6a658ce8526c --- /dev/null +++ b/codex-rs/core/src/session_prefix_tests.rs @@ -0,0 +1,20 @@ +use codex_protocol::AgentPath; +use codex_protocol::protocol::AgentStatus; +use codex_utils_output_truncation::approx_token_count; + +use super::COMPLETION_MESSAGE_MAX_TOKENS; +use super::ERROR_NEXT_ACTION; +use super::format_inter_agent_completion_message; + +#[test] +fn error_completion_message_stays_below_manual_review_threshold() { + let message = format_inter_agent_completion_message( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("valid agent path"), + &AgentStatus::Errored("stream disconnected ".repeat(1_000)), + ) + .expect("error status should produce a completion message"); + + assert!(approx_token_count(&message) < COMPLETION_MESSAGE_MAX_TOKENS); + assert!(message.contains(ERROR_NEXT_ACTION)); +} diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 0474eb870467..270134981b89 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/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 6e6c6cd51510..6a9509300301 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -76,7 +76,9 @@ impl ShellSnapshot { } let shell = environment.shell.clone()?; - let cwd = environment.cwd().clone(); + // TODO(anp): Migrate shell snapshot creation to accept PathUri and defer native + // conversion to the spawned shell process. + let cwd = environment.cwd().to_abs_path().ok()?; Self::build_for_cwd(Arc::clone(config), cwd, shell).await } diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index b11f79260a09..2c1ba8245afe 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -14,7 +14,7 @@ pub use codex_core_skills::SkillMetadata; pub use codex_core_skills::SkillPolicy; pub use codex_core_skills::SkillRenderReport; pub use codex_core_skills::SkillsLoadInput; -pub use codex_core_skills::SkillsManager; +pub use codex_core_skills::SkillsService; pub use codex_core_skills::build_available_skills; pub use codex_core_skills::build_skill_name_counts; pub use codex_core_skills::config_rules; @@ -26,11 +26,11 @@ pub use codex_core_skills::injection::SkillInjections; pub use codex_core_skills::injection::build_skill_injections; pub use codex_core_skills::injection::collect_explicit_skill_mentions; pub use codex_core_skills::loader; -pub use codex_core_skills::manager; pub use codex_core_skills::model; pub use codex_core_skills::remote; pub use codex_core_skills::render; pub use codex_core_skills::render::SkillRenderSideEffects; +pub use codex_core_skills::service; pub use codex_core_skills::system; pub(crate) fn skills_load_input_from_config( @@ -52,7 +52,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation( workdir: &AbsolutePathBuf, ) { let Some(candidate) = detect_implicit_skill_invocation_for_command( - turn_context.turn_skills.outcome.as_ref(), + turn_context.turn_skills.snapshot.outcome(), command, workdir, ) else { diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs index 1e3eceadade0..419e85be0460 100644 --- a/codex-rs/core/src/state/auto_compact_window.rs +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -1,4 +1,12 @@ 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 { @@ -13,7 +21,8 @@ enum AutoCompactWindowPrefill { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct AutoCompactWindow { - window_id: u64, + window_number: u64, + ids: AutoCompactWindowIds, new_context_window_requested: bool, /// Absolute input-token baseline for the current compaction window. /// @@ -21,14 +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_id: 0, + window_number: 0, + 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, } } @@ -36,18 +53,30 @@ impl AutoCompactWindow { self.prefill_input_tokens = None; } - pub(super) fn window_id(&self) -> u64 { - self.window_id + pub(super) fn window_number(&self) -> u64 { + self.window_number } - pub(super) fn set_window_id(&mut self, window_id: u64) { - self.window_id = window_id; + pub(super) fn ids(&self) -> AutoCompactWindowIds { + self.ids } - pub(super) fn advance_window_id(&mut self) -> u64 { - self.window_id = self.window_id.saturating_add(1); + pub(super) fn restore(&mut self, window_number: u64, ids: AutoCompactWindowIds) { + self.window_number = window_number; + self.ids = ids; + } + + pub(super) fn advance(&mut self) -> (u64, AutoCompactWindowIds) { + self.window_number = self.window_number.saturating_add(1); + 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_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) { @@ -108,16 +137,46 @@ mod tests { fn tracks_prefill_and_window_boundaries() { let mut window = AutoCompactWindow::new(); - assert_eq!(window.window_id(), 0); - window.set_window_id(/*window_id*/ 3); - assert_eq!(window.window_id(), 3); + assert_eq!(window.window_number(), 0); + 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(); + 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.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(); - assert_eq!(window.advance_window_id(), 4); - assert_eq!(window.window_id(), 4); + let (window_number, ids) = window.advance(); + assert_eq!(window_number, 4); + assert_eq!(window.window_number(), 4); + 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 c693221bedca..20ff78b54fd4 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/service.rs b/codex-rs/core/src/state/service.rs index ee35df28d119..a3e24cec2bb6 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,12 +1,14 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; -use crate::SkillsManager; +use crate::SkillsService; use crate::agent::AgentControl; use crate::attestation::AttestationProvider; use crate::client::ModelClient; use crate::config::NetworkProxyAuditMetadata; use crate::config::StartedNetworkProxy; +use crate::current_time::TimeProvider; use crate::environment_selection::ThreadEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; @@ -63,12 +65,13 @@ pub(crate) struct SessionServices { pub(crate) guardian_rejections: Mutex>, pub(crate) guardian_rejection_circuit_breaker: Mutex, pub(crate) runtime_handle: Handle, - pub(crate) skills_manager: Arc, + pub(crate) skills_service: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, pub(crate) extensions: Arc>, pub(crate) session_extension_data: ExtensionData, pub(crate) thread_extension_data: ExtensionData, + pub(crate) supports_openai_form_elicitation: AtomicBool, pub(crate) mcp_thread_init: ExtensionDataInit, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: ArcSwapOption, @@ -79,6 +82,7 @@ pub(crate) struct SessionServices { pub(crate) live_thread: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, + pub(crate) time_provider: Arc, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, pub(crate) code_mode_service: CodeModeService, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 269d3e0f607e..6444dc8cf306 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -9,10 +9,12 @@ use std::collections::VecDeque; 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; use crate::session::session::SessionConfiguration; +use crate::session::time_reminder::CurrentTimeReminderState; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; @@ -36,6 +38,7 @@ pub(crate) struct SessionState { auto_compact_window: AutoCompactWindow, /// Startup prewarmed session prepared during session initialization. pub(crate) startup_prewarm: Option, + pub(crate) current_time_reminder: CurrentTimeReminderState, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_sources: VecDeque, granted_permissions_by_environment_id: HashMap, @@ -56,6 +59,7 @@ impl SessionState { previous_turn_settings: None, auto_compact_window: AutoCompactWindow::new(), startup_prewarm: None, + current_time_reminder: CurrentTimeReminderState::default(), active_connector_selection: HashSet::new(), pending_session_start_sources: VecDeque::new(), granted_permissions_by_environment_id: HashMap::new(), @@ -144,30 +148,44 @@ impl SessionState { self.auto_compact_window.snapshot() } - pub(crate) fn auto_compact_window_id(&self) -> u64 { - self.auto_compact_window.window_id() + pub(crate) fn claim_token_budget_reminder(&mut self) -> bool { + self.auto_compact_window.claim_token_budget_reminder() } - pub(crate) fn set_auto_compact_window_id(&mut self, window_id: u64) { - self.auto_compact_window.set_window_id(window_id); + pub(crate) fn auto_compact_window_number(&self) -> u64 { + self.auto_compact_window.window_number() } - pub(crate) fn advance_auto_compact_window_id(&mut self) -> u64 { - self.auto_compact_window.advance_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, + ids: AutoCompactWindowIds, + ) { + self.auto_compact_window.restore(window_number, ids); + } + + pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, AutoCompactWindowIds) { + self.auto_compact_window.advance() } pub(crate) fn request_new_context_window(&mut self) { self.auto_compact_window.request_new_context_window(); } - pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option { + 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; } - let window_id = self.auto_compact_window.advance_window_id(); + let window = self.auto_compact_window.advance(); self.auto_compact_window.clear_prefill(); - Some(window_id) + Some(window) } pub(crate) fn token_info(&self) -> Option { diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 265d7b450fea..71a9ca7d2191 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -579,7 +579,7 @@ pub(crate) async fn finalize_turn_item( } } if let TurnItem::ImageGeneration(image_item) = &mut *turn_item - && image_item.status == "completed" + && !image_item.result.is_empty() { persist_image_generation_item(sess, turn_context, image_item).await; } @@ -624,9 +624,10 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti match input { ResponseInputItem::FunctionCallOutput { call_id, output } => { Some(ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: output.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::CustomToolCallOutput { @@ -634,17 +635,19 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti name, output, } => Some(ResponseItem::CustomToolCallOutput { + id: None, 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(); Some(ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::ToolSearchOutput { @@ -653,11 +656,12 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti execution, tools, } => Some(ResponseItem::ToolSearchOutput { + id: None, call_id: Some(call_id.clone()), 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 fdb8cddf4678..b01ed4c4ca06 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,14 +61,15 @@ 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, call_id: Some("search-1".to_string()), status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -93,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, @@ -101,12 +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, @@ -114,13 +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"), ]; @@ -279,9 +282,9 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() { let router = Arc::new(ToolRouter::from_turn_context( &turn_context, crate::tools::router::ToolRouterParams { + tool_suggest_candidates: None, mcp_tools: None, deferred_mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, @@ -418,11 +421,11 @@ fn completed_item_keeps_mailbox_delivery_open_for_commentary_messages() { #[test] fn completed_item_defers_mailbox_delivery_for_image_generation_calls() { let item = ResponseItem::ImageGenerationCall { - id: "ig-1".to_string(), + id: Some("ig-1".to_string()), 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/compact.rs b/codex-rs/core/src/tasks/compact.rs index adc98e413a8f..2c45fd8ee2a8 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -2,10 +2,12 @@ use std::sync::Arc; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; use super::emit_compact_metric; use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; +use codex_protocol::error::CodexErr; use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; @@ -27,10 +29,11 @@ impl SessionTask for CompactTask { ctx: Arc, _input: Vec, _cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let session = session.clone_session(); - let _ = if crate::compact::should_use_remote_compact_task(ctx.provider.info()) { + let result = if crate::compact::should_use_remote_compact_task(ctx.provider.info()) { if ctx + .config .features .enabled(codex_features::Feature::RemoteCompactionV2) { @@ -55,12 +58,20 @@ impl SessionTask for CompactTask { /*manual*/ true, ); let input = vec![UserInput::Text { - text: ctx.compact_prompt().to_string(), + text: ctx + .config + .compact_prompt + .as_deref() + .unwrap_or(crate::compact::SUMMARIZATION_PROMPT) + .to_string(), // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; crate::compact::run_compact_task(session.clone(), ctx, input).await }; - None + if let Err(err @ CodexErr::TurnAborted) = result { + return Err(err); + } + Ok(None) } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 947def5cfcd7..ea4769a97f5c 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -55,6 +55,8 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::WarningEvent; use codex_features::Feature; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ContentItem; pub(crate) use compact::CompactTask; pub(crate) use regular::RegularTask; @@ -69,6 +71,8 @@ pub(crate) use workflow_command::record_workflow_output; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; const TASK_COMPACT_METRIC: &str = "codex.task.compact"; +pub(crate) type SessionTaskResult = CodexResult>; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InterruptedTurnHistoryMarker { Disabled, @@ -113,7 +117,7 @@ pub(crate) fn interrupted_turn_history_marker( text: marker.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } } @@ -226,14 +230,16 @@ pub(crate) trait SessionTask: Send + Sync + 'static { /// provided `cancellation_token` is cancelled when the session requests an /// abort; implementers should watch for it and terminate quickly once it /// fires. Returning [`Some`] yields a final message that - /// [`Session::on_task_finished`] will emit to the client. + /// [`Session::on_task_finished`] will emit to the client. Returning + /// [`CodexErr::TurnAborted`] completes the task through the aborted-turn + /// lifecycle instead. fn run( self: Arc, session: Arc, ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> impl std::future::Future> + Send; + ) -> impl std::future::Future + Send; /// Gives the task a chance to perform cleanup after an abort. /// @@ -262,7 +268,7 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static { ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> BoxFuture<'static, Option>; + ) -> BoxFuture<'static, SessionTaskResult>; fn abort<'a>( &'a self, @@ -289,7 +295,7 @@ where ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> BoxFuture<'static, Option> { + ) -> BoxFuture<'static, SessionTaskResult> { Box::pin(SessionTask::run( self, session, @@ -399,7 +405,7 @@ impl Session { let handle = tokio::spawn( async move { let ctx_for_finish = Arc::clone(&ctx); - let last_agent_message = task_for_run + let task_result = task_for_run .run( Arc::clone(&session_ctx), ctx, @@ -422,8 +428,8 @@ impl Session { .await; } if !task_cancellation_token.is_cancelled() { - // Emit completion uniformly from spawn site so all tasks share the same lifecycle. - sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message) + // Finish uniformly from the spawn site so all tasks share the same lifecycle. + sess.on_task_finished(Arc::clone(&ctx_for_finish), task_result) .await; } done_clone.notify_waiters(); @@ -561,8 +567,16 @@ impl Session { pub async fn on_task_finished( self: &Arc, turn_context: Arc, - last_agent_message: Option, + task_result: SessionTaskResult, ) { + let (last_agent_message, abort_reason) = match task_result { + Ok(last_agent_message) => (last_agent_message, None), + Err(CodexErr::TurnAborted) => (None, Some(TurnAbortReason::Interrupted)), + Err(err) => { + warn!(%err, "session task returned an unexpected error"); + (None, None) + } + }; turn_context .turn_metadata_state .cancel_git_enrichment_task(); @@ -726,7 +740,7 @@ impl Session { } emit_turn_memory_metric( &self.services.session_telemetry, - turn_context.features.enabled(Feature::MemoryTool), + turn_context.config.features.enabled(Feature::MemoryTool), turn_context.config.memories.use_memories, turn_had_memory_citation, ); @@ -734,25 +748,36 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; - let time_to_first_token_ms = turn_context - .turn_timing_state - .time_to_first_token_ms() - .await; self.services .analytics_events_client .track_turn_profile(TurnProfileFact { turn_id: turn_context.sub_id.clone(), profile: turn_context.turn_timing_state.complete_profile(), }); - self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) - .await; - let event = EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: turn_context.sub_id.clone(), - last_agent_message, - completed_at, - duration_ms, - time_to_first_token_ms, - }); + let event = if let Some(reason) = abort_reason { + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) + .await; + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn_context.sub_id.clone()), + reason, + completed_at, + duration_ms, + }) + } else { + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; + self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) + .await; + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_context.sub_id.clone(), + last_agent_message, + completed_at, + duration_ms, + time_to_first_token_ms, + }) + }; self.send_event(turn_context.as_ref(), event).await; self.services .guardian_rejection_circuit_breaker diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index c2ffa361a241..9a10d6e1d59a 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -18,6 +18,7 @@ use tracing::trace_span; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; #[derive(Default)] pub(crate) struct RegularTask; @@ -43,7 +44,7 @@ impl SessionTask for RegularTask { ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let sess = session.clone_session(); let turn_extension_data = session.turn_extension_data(); let previous_model = ctx.model_info.slug.clone(); @@ -149,7 +150,7 @@ impl SessionTask for RegularTask { .instrument(trace_span!("regular_task.prepare_run_turn")) .await; let prewarmed_client_session = match prewarmed_client_session { - SessionStartupPrewarmResolution::Cancelled => return None, + SessionStartupPrewarmResolution::Cancelled => return Ok(None), SessionStartupPrewarmResolution::Unavailable { .. } => None, SessionStartupPrewarmResolution::Ready(mut prewarmed_client_session) => { if model_router_route_changed { @@ -172,9 +173,9 @@ impl SessionTask for RegularTask { cancellation_token.child_token(), ) .instrument(run_turn_span.clone()) - .await; + .await?; if !sess.input_queue.has_pending_input(&sess.active_turn).await { - return last_agent_message; + return Ok(last_agent_message); } next_input = Vec::new(); } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 9c195ee546ec..b487025cb399 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -31,6 +31,7 @@ use codex_protocol::user_input::UserInput; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; #[derive(Clone, Copy)] pub(crate) struct ReviewTask; @@ -56,7 +57,7 @@ impl SessionTask for ReviewTask { ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { session.session.services.session_telemetry.counter( "codex.task.review", /*inc*/ 1, @@ -86,7 +87,7 @@ impl SessionTask for ReviewTask { if !cancellation_token.is_cancelled() { exit_review_mode(session.clone_session(), output.clone(), ctx.clone()).await; } - None + Ok(None) } async fn abort(&self, session: Arc, ctx: Arc) { @@ -261,7 +262,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; @@ -282,7 +283,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 6a01a87a0e06..844e327bdf90 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -41,6 +41,7 @@ use codex_shell_command::parse_command::parse_command; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; use crate::session::session::Session; use codex_protocol::models::PermissionProfile; @@ -82,7 +83,7 @@ impl SessionTask for UserShellCommandTask { turn_context: Arc, _input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { execute_user_shell_command( session.clone_session(), turn_context, @@ -91,7 +92,7 @@ impl SessionTask for UserShellCommandTask { UserShellCommandMode::StandaloneTurn, ) .await; - None + Ok(None) } } @@ -144,9 +145,20 @@ pub(crate) async fn execute_user_shell_command( // We do not source rc files or otherwise reformat the script. let use_login_shell = true; let display_command = environment_shell.derive_exec_args(&command, use_login_shell); - let shell_snapshot_location = turn_environment.shell_snapshot(turn_environment.cwd()); + // TODO(anp): Migrate user-shell events and execution plumbing to PathUri so this local-only + // feature does not need to project the selected environment cwd onto the Codex host. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + send_user_shell_error( + &session, + turn_context.as_ref(), + "shell working directory is not native to the Codex host", + ) + .await; + return; + }; + let shell_snapshot_location = turn_environment.shell_snapshot(&cwd); let mut exec_env_map = create_env( - &turn_context.shell_environment_policy, + &turn_context.config.permissions.shell_environment_policy, Some(session.thread_id), ); if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) { @@ -156,13 +168,16 @@ pub(crate) async fn execute_user_shell_command( &display_command, environment_shell, shell_snapshot_location.as_ref(), - &turn_context.shell_environment_policy.r#set, + &turn_context + .config + .permissions + .shell_environment_policy + .r#set, &mut exec_env_map, ); let call_id = Uuid::new_v4().to_string(); let raw_command = command; - let cwd = turn_environment.cwd().clone(); let parsed_cmd = parse_command(&display_command); session @@ -174,7 +189,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), started_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -185,18 +200,19 @@ pub(crate) async fn execute_user_shell_command( let permission_profile = PermissionProfile::Disabled; let exec_env = ExecRequest { command: exec_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), env: exec_env_map, exec_server_env_config: None, // `/shell` is the explicit full-access escape hatch, so it must not // inherit a managed proxy from the surrounding session or turn. network: None, + network_environment_id: None, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), capture_policy: ExecCapturePolicy::ShellTool, sandbox: SandboxType::None, - windows_sandbox_policy_cwd: cwd.clone(), + windows_sandbox_policy_cwd: cwd.clone().into(), windows_sandbox_workspace_roots: turn_context.config.effective_workspace_roots(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context @@ -208,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 { @@ -248,7 +266,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -273,7 +291,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -284,7 +302,7 @@ pub(crate) async fn execute_user_shell_command( duration: output.duration, formatted_output: format_exec_output_str( &output, - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ), status: if output.exit_code == 0 { ExecCommandStatus::Completed @@ -318,7 +336,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command, - cwd, + cwd: cwd.into(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, @@ -329,7 +347,7 @@ pub(crate) async fn execute_user_shell_command( duration: exec_output.duration, formatted_output: format_exec_output_str( &exec_output, - turn_context.truncation_policy, + turn_context.model_info.truncation_policy.into(), ), status: ExecCommandStatus::Failed, }), diff --git a/codex-rs/core/src/tasks/workflow_command.rs b/codex-rs/core/src/tasks/workflow_command.rs index 12fdd6dbbc3b..0cf4cb3a27a9 100644 --- a/codex-rs/core/src/tasks/workflow_command.rs +++ b/codex-rs/core/src/tasks/workflow_command.rs @@ -15,6 +15,7 @@ use tokio_util::sync::CancellationToken; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -84,7 +85,7 @@ impl SessionTask for WorkflowCommandTask { turn_context: Arc, _input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { session.session.services.session_telemetry.counter( "codex.task.workflow_command", /*inc*/ 1, @@ -99,7 +100,7 @@ impl SessionTask for WorkflowCommandTask { .await { Ok(Some(markdown)) => markdown, - Ok(None) => return None, + Ok(None) => return Ok(None), Err(message) => { session .clone_session() @@ -111,11 +112,13 @@ impl SessionTask for WorkflowCommandTask { }), ) .await; - return None; + return Ok(None); } }; - Some(record_workflow_output(session.clone_session(), turn_context, markdown).await) + Ok(Some( + record_workflow_output(session.clone_session(), turn_context, markdown).await, + )) } } @@ -135,7 +138,7 @@ pub(crate) async fn record_workflow_output( text: markdown.clone(), }], phase: Some(MessagePhase::FinalAnswer), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ) .await; diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index d600d5026a16..d4c383e50ca7 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -112,9 +112,14 @@ pub async fn start_thread_with_user_shell_override( thread_manager: &ThreadManager, config: Config, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> codex_protocol::error::Result { thread_manager - .start_thread_with_user_shell_override_for_tests(config, user_shell_override) + .start_thread_with_user_shell_override_for_tests( + config, + user_shell_override, + supports_openai_form_elicitation, + ) .await } @@ -124,6 +129,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override( rollout_path: PathBuf, auth_manager: Arc, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> codex_protocol::error::Result { thread_manager .resume_thread_from_rollout_with_user_shell_override_for_tests( @@ -131,6 +137,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override( rollout_path, auth_manager, user_shell_override, + supports_openai_form_elicitation, ) .await } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 318cff2c670f..dcb1fb7523f2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1,9 +1,10 @@ -use crate::SkillsManager; +use crate::SkillsService; use crate::agent::AgentControl; use crate::attestation::AttestationProvider; use crate::codex_thread::CodexThread; use crate::config::Config; use crate::config::ThreadStoreConfig; +use crate::current_time::TimeProvider; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::environment_selection::default_thread_environment_selections; use crate::mcp::McpManager; @@ -36,6 +37,7 @@ use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::openai_models::ModelPreset; @@ -184,9 +186,11 @@ pub struct StartThreadOptions { pub thread_source: Option, pub dynamic_tools: Vec, pub metrics_service_name: Option, + pub multi_agent_mode: Option, pub parent_trace: Option, pub environments: Vec, pub thread_extension_init: ExtensionDataInit, + pub supports_openai_form_elicitation: bool, } pub(crate) struct ResumeThreadWithHistoryOptions { @@ -208,13 +212,14 @@ pub(crate) struct ThreadManagerState { auth_manager: Arc, models_manager: SharedModelsManager, environment_manager: Arc, - skills_manager: Arc, + skills_service: Arc, plugins_manager: Arc, mcp_manager: Arc, extensions: Arc>, user_instructions_provider: Arc, thread_store: Arc, attestation_provider: Option>, + external_time_provider: Option>, session_source: SessionSource, installation_id: String, analytics_events_client: Option, @@ -279,6 +284,7 @@ impl ThreadManager { state_db: Option, installation_id: String, attestation_provider: Option>, + external_time_provider: Option>, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -292,7 +298,7 @@ impl ThreadManager { Arc::clone(&plugins_manager), Arc::clone(&extensions), )); - let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( + let skills_service = Arc::new(SkillsService::new_with_restriction_product( codex_home, config.bundled_skills_enabled(), restriction_product, @@ -303,13 +309,14 @@ impl ThreadManager { thread_created_tx, models_manager: build_models_manager(config, auth_manager.clone()), environment_manager, - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions, user_instructions_provider, thread_store, attestation_provider, + external_time_provider, auth_manager, session_source, installation_id, @@ -384,7 +391,7 @@ impl ThreadManager { auth_manager.get_api_auth_mode(), )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( + let skills_service = Arc::new(SkillsService::new_with_restriction_product( skills_codex_home, /*bundled_skills_enabled*/ true, restriction_product, @@ -410,7 +417,7 @@ impl ThreadManager { CollaborationModesConfig::default(), ), environment_manager, - skills_manager, + skills_service, plugins_manager, mcp_manager, extensions: empty_extension_registry(), @@ -419,6 +426,7 @@ impl ThreadManager { ), thread_store, attestation_provider: None, + external_time_provider: None, auth_manager, session_source: SessionSource::Exec, installation_id, @@ -439,8 +447,8 @@ impl ThreadManager { self.state.auth_manager.clone() } - pub fn skills_manager(&self) -> Arc { - self.state.skills_manager.clone() + pub fn skills_service(&self) -> Arc { + self.state.skills_service.clone() } pub fn plugins_manager(&self) -> Arc { @@ -616,9 +624,11 @@ impl ThreadManager { thread_source: None, dynamic_tools, metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: ExtensionDataInit::default(), + supports_openai_form_elicitation: false, })) .await } @@ -636,6 +646,7 @@ impl ThreadManager { options: StartThreadOptions, forked_from_thread_id: Option, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&options.config); let (resumed_session_source, resumed_thread_source) = options .initial_history .get_resumed_session_sources() @@ -646,18 +657,20 @@ impl ThreadManager { options.config, options.initial_history, Arc::clone(&self.state.auth_manager), - self.agent_control(), + agent_control, session_source, /*parent_thread_id*/ None, forked_from_thread_id, thread_source, options.dynamic_tools, options.metrics_service_name, + options.multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, options.parent_trace, options.environments, options.thread_extension_init, + options.supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -706,6 +719,7 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult { let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; Box::pin(self.resume_thread_with_history( @@ -713,6 +727,7 @@ impl ThreadManager { initial_history, auth_manager, parent_trace, + supports_openai_form_elicitation, )) .await } @@ -724,7 +739,9 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&config); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, @@ -732,22 +749,25 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, auth_manager, - self.agent_control(), + agent_control, session_source, /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -757,7 +777,9 @@ impl ThreadManager { &self, config: Config, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&config); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, @@ -766,15 +788,17 @@ impl ThreadManager { config, InitialHistory::New, Arc::clone(&self.state.auth_manager), - self.agent_control(), + agent_control, /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, /*thread_source*/ None, Vec::new(), /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -786,7 +810,9 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&config); let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -795,22 +821,25 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, auth_manager, - self.agent_control(), + agent_control, session_source, /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -891,8 +920,15 @@ impl ThreadManager { { let snapshot = snapshot.into(); let history = self.initial_history_from_rollout_path(path).await?; - self.fork_thread_from_history(snapshot, config, history, thread_source, parent_trace) - .await + self.fork_thread_from_history( + snapshot, + config, + history, + thread_source, + parent_trace, + /*supports_openai_form_elicitation*/ false, + ) + .await } async fn initial_history_from_rollout_path( @@ -921,6 +957,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult where S: Into, @@ -931,6 +968,7 @@ impl ThreadManager { history, thread_source, parent_trace, + supports_openai_form_elicitation, ) .await } @@ -942,21 +980,29 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. - let forked_from_thread_id = match &history { + let source_thread_id = match &history { InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; + let initial_multi_agent_mode = match source_thread_id { + Some(thread_id) => match self.get_thread(thread_id).await { + 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(), + }; let multi_agent_version = self .state .effective_multi_agent_version_for_spawn( &history, /*session_source*/ None, /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, &config, ) .await; @@ -967,26 +1013,33 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); + let agent_control = self.agent_control_for_config(&config); Box::pin(self.state.spawn_thread( config, history, Arc::clone(&self.state.auth_manager), - self.agent_control(), + agent_control, /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, parent_trace, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await } pub(crate) fn agent_control(&self) -> AgentControl { - AgentControl::new(Arc::downgrade(&self.state)) + AgentControl::new(Arc::downgrade(&self.state), /*rollout_budget*/ None) + } + + fn agent_control_for_config(&self, config: &Config) -> AgentControl { + AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget.clone()) } #[cfg(test)] @@ -1196,6 +1249,7 @@ impl ThreadManagerState { /*forked_from_thread_id*/ None, /*thread_source*/ None, /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*environments*/ None, @@ -1213,6 +1267,7 @@ impl ThreadManagerState { forked_from_thread_id: Option, thread_source: Option, metrics_service_name: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1231,11 +1286,13 @@ impl ThreadManagerState { thread_source, Vec::new(), metrics_service_name, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1257,6 +1314,7 @@ impl ThreadManagerState { let environments = default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); let thread_source = initial_history.get_resumed_thread_source(); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.spawn_thread_with_source( config, initial_history, @@ -1268,11 +1326,13 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1288,6 +1348,7 @@ impl ThreadManagerState { thread_source: Option, parent_thread_id: Option, forked_from_thread_id: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1306,11 +1367,13 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1329,9 +1392,11 @@ impl ThreadManagerState { thread_source: Option, dynamic_tools: Vec, metrics_service_name: Option, + initial_multi_agent_mode: Option, parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( @@ -1345,11 +1410,13 @@ impl ThreadManagerState { thread_source, dynamic_tools, metrics_service_name, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, environments, thread_extension_init, + supports_openai_form_elicitation, user_shell_override, )) .await @@ -1368,11 +1435,13 @@ impl ThreadManagerState { thread_source: Option, dynamic_tools: Vec, metrics_service_name: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, user_shell_override: Option, ) -> CodexResult { let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_)); @@ -1421,7 +1490,7 @@ impl ThreadManagerState { auth_manager, models_manager: Arc::clone(&self.models_manager), environment_manager: Arc::clone(&self.environment_manager), - skills_manager: Arc::clone(&self.skills_manager), + skills_service: Arc::clone(&self.skills_service), plugins_manager: Arc::clone(&self.plugins_manager), mcp_manager: Arc::clone(&self.mcp_manager), extensions: Arc::clone(&self.extensions), @@ -1440,10 +1509,13 @@ impl ThreadManagerState { parent_trace, environment_selections: environments, thread_extension_init, + supports_openai_form_elicitation, analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), + external_time_provider: self.external_time_provider.clone(), inherited_multi_agent_version: multi_agent_version, + initial_multi_agent_mode, })) .await?; let new_thread = self diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ddfa5e701123..afb103b883b2 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, } } @@ -76,13 +76,13 @@ fn truncates_before_requested_user_message() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: "r1".to_string(), + id: Some("r1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], 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"), ]; @@ -322,9 +322,11 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("internal thread should start"); @@ -439,6 +441,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let selected_root_init = |id: &str, environment_id: &str| { let mut init = codex_extension_api::ExtensionDataInit::new(); @@ -460,9 +463,11 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-a", "env-a"), + supports_openai_form_elicitation: false, }) .await .expect("start first thread"); @@ -474,9 +479,11 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-b", "env-b"), + supports_openai_form_elicitation: false, }) .await .expect("start second thread"); @@ -545,6 +552,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let selected_cwd = AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path"); @@ -564,9 +572,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: environments.clone(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("start source thread"); @@ -593,6 +603,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume source thread"); @@ -606,11 +617,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(resumed_turn.environments.turn_environments.len(), 1); assert_eq!( resumed_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&default_cwd) ); assert_ne!( resumed_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&selected_cwd) ); let forked = manager @@ -633,11 +644,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(forked_turn.environments.turn_environments.len(), 1); assert_eq!( forked_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&default_cwd) ); assert_ne!( forked_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&selected_cwd) ); } @@ -666,6 +677,7 @@ async fn explicit_installation_id_skips_codex_home_file() { state_db.clone(), installation_id.clone(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let thread = manager @@ -706,6 +718,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -729,6 +742,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume active source thread"); @@ -764,6 +778,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -792,6 +807,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume stopped source thread"); @@ -829,6 +845,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -839,9 +856,11 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { thread_source: Some(ThreadSource::User), dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("start source thread"); @@ -868,6 +887,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume source thread"); @@ -920,6 +940,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { state_db, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -947,6 +968,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { }), auth_manager.clone(), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("seed rollout path in store"); @@ -963,6 +985,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume from rollout path"); @@ -1022,6 +1045,7 @@ async fn new_uses_active_provider_for_model_refresh() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -1243,6 +1267,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -1254,6 +1279,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from completed history"); @@ -1350,6 +1376,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -1368,6 +1395,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from explicit partial history"); @@ -1447,6 +1475,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -1458,6 +1487,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from partial history"); diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index bb6c3ac03e7b..2df3d1c98ed4 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, } } @@ -73,13 +73,13 @@ fn truncates_rollout_from_start_before_nth_user_only() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: "r1".to_string(), + id: Some("r1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], 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 15177944ca0c..c467fe0d6fe1 100644 --- a/codex-rs/core/src/tools/code_mode/delegate.rs +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -299,10 +299,11 @@ impl CoreTurnHost { self.exec .session .inject_if_running(vec![ResponseItem::CustomToolCallOutput { + id: None, 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/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index ade23cf3b770..09918033e667 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -26,6 +26,7 @@ use crate::tools::ToolRouter; use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; +use crate::tools::effective_tool_mode; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; @@ -116,7 +117,8 @@ impl CodeModeService { router: Arc, tracker: SharedTurnDiffTracker, ) -> Option { - if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) + let tool_mode = effective_tool_mode(turn); + if !matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) || self.session.is_none() { return None; @@ -321,8 +323,10 @@ fn build_freeform_tool_payload( #[cfg(test)] mod tests { use super::build_nested_tool_payload; + use super::truncate_code_mode_result; use crate::tools::context::ToolPayload; use codex_code_mode::CodeModeToolKind; + use codex_protocol::models::FunctionCallOutputContentItem; use codex_tools::ToolName; use serde_json::json; @@ -359,4 +363,23 @@ mod tests { other => panic!("expected freeform payload, got {other:?}"), } } + + #[test] + fn truncated_text_output_starts_with_warning() { + let items = vec![FunctionCallOutputContentItem::InputText { + text: "0123456789012345678901234567890123456789".to_string(), + }]; + + assert_eq!( + truncate_code_mode_result(items, Some(5)), + vec![FunctionCallOutputContentItem::InputText { + text: concat!( + "Warning: truncated output (original token count: 10)\n", + "Total output lines: 1\n\n", + "0123456789…5 tokens truncated…0123456789" + ) + .to_string(), + }] + ); + } } diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 2751780d5858..34c41d835da0 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -21,6 +21,7 @@ use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::TurnDiffEvent; use codex_shell_command::parse_command::parse_command; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -95,7 +96,7 @@ fn tracker_update_for_known_delta<'a>( pub(crate) async fn emit_exec_command_begin( ctx: ToolEventCtx<'_>, command: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, parsed_cmd: &[ParsedCommand], source: ExecCommandSource, interaction_input: Option, @@ -122,7 +123,7 @@ pub(crate) async fn emit_exec_command_begin( pub(crate) enum ToolEmitter { Shell { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, }, @@ -133,7 +134,7 @@ pub(crate) enum ToolEmitter { }, UnifiedExec { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, process_id: Option, @@ -145,7 +146,7 @@ impl ToolEmitter { let parsed_cmd = parse_command(&command); Self::Shell { command, - cwd, + cwd: PathUri::from_abs_path(&cwd), source, parsed_cmd, } @@ -165,7 +166,7 @@ impl ToolEmitter { pub fn unified_exec( command: &[String], - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, process_id: Option, ) -> Self { @@ -346,7 +347,7 @@ impl ToolEmitter { output: &ExecToolCallOutput, ctx: ToolEventCtx<'_>, ) -> String { - super::format_exec_output_for_model(output, ctx.turn.truncation_policy) + super::format_exec_output_for_model(output, ctx.turn.model_info.truncation_policy.into()) } pub async fn finish( @@ -432,7 +433,7 @@ impl ToolEmitter { struct ExecCommandInput<'a> { command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, @@ -442,7 +443,7 @@ struct ExecCommandInput<'a> { impl<'a> ExecCommandInput<'a> { fn new( command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, @@ -495,7 +496,10 @@ async fn emit_exec_stage( aggregated_output: output.aggregated_output.text.clone(), exit_code: output.exit_code, duration: output.duration, - formatted_output: format_exec_output_str(&output, ctx.turn.truncation_policy), + formatted_output: format_exec_output_str( + &output, + ctx.turn.model_info.truncation_policy.into(), + ), status: if output.exit_code == 0 { ExecCommandStatus::Completed } else { @@ -628,7 +632,7 @@ mod tests { use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::items::TurnItem; use codex_protocol::protocol::PatchApplyStatus; - use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use std::sync::Arc; use tempfile::tempdir; use tokio::sync::Mutex; @@ -641,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 = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = codex_apply_patch::apply_patch( @@ -725,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 = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); for patch in [ "*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch", @@ -778,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 = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_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/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 2556989621cb..a519e5db1b08 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -299,7 +299,7 @@ pub async fn handle( Ok(FunctionToolOutput::from_text(content, Some(true))) } -fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, FunctionCallError> { +fn single_local_environment_cwd(turn: &TurnContext) -> Result { let [turn_environment] = turn.environments.turn_environments.as_slice() else { return Err(FunctionCallError::RespondToModel( "spawn_agents_on_csv requires exactly one local environment".to_string(), @@ -312,5 +312,12 @@ fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, )); } - Ok(turn_environment.cwd()) + // TODO(anp): Migrate spawn_agents_on_csv filesystem access to PathUri before enabling it for + // remote environments. + turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "spawn_agents_on_csv cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + }) } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index a719e9acd9f6..5bb2b013c3bd 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -82,7 +82,11 @@ impl ToolArgumentDiffConsumer for ApplyPatchArgumentDiffConsumer { call_id: String, diff: &str, ) -> Option { - if !turn.features.enabled(Feature::ApplyPatchStreamingEvents) { + if !turn + .config + .features + .enabled(Feature::ApplyPatchStreamingEvents) + { return None; } @@ -199,30 +203,21 @@ fn format_update_chunks_for_progress(chunks: &[codex_apply_patch::UpdateFileChun unified_diff } -fn file_paths_for_action(action: &ApplyPatchAction) -> Vec { +fn file_paths_for_action(action: &ApplyPatchAction) -> Vec { let mut keys = Vec::new(); - let cwd = &action.cwd; - for (path, change) in action.changes() { - if let Some(key) = to_abs_path(cwd, path) { - keys.push(key); - } + keys.push(path.clone()); if let ApplyPatchFileChange::Update { move_path, .. } = change && let Some(dest) = move_path - && let Some(key) = to_abs_path(cwd, dest) { - keys.push(key); + keys.push(dest.clone()); } } keys } -fn to_abs_path(cwd: &AbsolutePathBuf, path: &Path) -> Option { - Some(AbsolutePathBuf::resolve_path_against_base(path, cwd)) -} - fn write_permissions_for_paths( file_paths: &[AbsolutePathBuf], file_system_sandbox_policy: &codex_protocol::permissions::FileSystemSandboxPolicy, @@ -268,13 +263,14 @@ async fn effective_patch_permissions( turn: &TurnContext, environment_id: &str, action: &ApplyPatchAction, - cwd: &AbsolutePathBuf, -) -> ( - Vec, + cwd: &PathUri, +) -> std::io::Result<( + Vec, crate::tools::handlers::EffectiveAdditionalPermissions, codex_protocol::permissions::FileSystemSandboxPolicy, -) { +)> { let file_paths = file_paths_for_action(action); + let native_cwd = cwd.to_abs_path()?; let granted_permissions = merge_permission_profiles( session .granted_session_permissions(environment_id) @@ -290,19 +286,43 @@ async fn effective_patch_permissions( &base_file_system_sandbox_policy, granted_permissions.as_ref(), ); + let native_file_paths = file_paths + .iter() + .map(PathUri::to_abs_path) + .collect::, _>>()?; let effective_additional_permissions = apply_granted_turn_permissions( session, environment_id, - cwd.as_path(), + native_cwd.as_path(), crate::sandboxing::SandboxPermissions::UseDefault, - write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd), + write_permissions_for_paths(&native_file_paths, &file_system_sandbox_policy, &native_cwd), ) .await; - ( + Ok(( file_paths, effective_additional_permissions, file_system_sandbox_policy, + )) +} + +fn patch_permissions_without_path_matching( + action: &ApplyPatchAction, +) -> ( + Vec, + crate::tools::handlers::EffectiveAdditionalPermissions, + codex_protocol::permissions::FileSystemSandboxPolicy, +) { + // TODO(anp): Make permission matching operate on PathUri. Until then, foreign paths skip + // permission matching; a managed turn still fails closed at the platform sandbox boundary. + ( + file_paths_for_action(action), + crate::tools::handlers::EffectiveAdditionalPermissions { + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + permissions_preapproved: false, + }, + codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(), ) } @@ -359,14 +379,18 @@ impl ApplyPatchHandler { "apply_patch is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); let fs = turn_environment.environment.get_filesystem(); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, - turn_environment.cwd_uri(), + turn_environment.cwd(), ); - match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) - .await + match codex_apply_patch::verify_apply_patch_args( + args, + turn_environment.cwd(), + fs.as_ref(), + Some(&sandbox), + ) + .await { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = @@ -375,9 +399,10 @@ impl ApplyPatchHandler { turn.as_ref(), &turn_environment.environment_id, &changes, - &cwd, + turn_environment.cwd(), ) - .await; + .await + .unwrap_or_else(|_| patch_permissions_without_path_matching(&changes)); match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { @@ -517,7 +542,7 @@ impl CoreToolRuntime for ApplyPatchHandler { #[allow(clippy::too_many_arguments)] pub(crate) async fn intercept_apply_patch( command: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, turn_environment: TurnEnvironment, session: Arc, @@ -526,9 +551,7 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox_cwd = PathUri::from_abs_path(cwd); - let sandbox = - turn.file_system_sandbox_context(/*additional_permissions*/ None, &sandbox_cwd); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { @@ -541,7 +564,8 @@ pub(crate) async fn intercept_apply_patch( &changes, cwd, ) - .await; + .await + .unwrap_or_else(|_| patch_permissions_without_path_matching(&changes)); match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index e9a118641a4f..f8d5f9710e22 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -225,6 +225,8 @@ async fn approval_keys_include_move_destination() { +new content *** End Patch"#; let argv = vec!["apply_patch".to_string(), patch.to_string()]; + // TODO(anp): Keep apply_patch handler test cwd values as PathUri. + let cwd = PathUri::from_abs_path(&cwd); let action = match codex_apply_patch::maybe_parse_apply_patch_verified( &argv, &cwd, diff --git a/codex-rs/core/src/tools/handlers/current_time.rs b/codex-rs/core/src/tools/handlers/current_time.rs new file mode 100644 index 000000000000..73873ca74125 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/current_time.rs @@ -0,0 +1,108 @@ +use crate::context::ContextualUserFragment; +use crate::context::CurrentTimeReminder; +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; +use codex_protocol::models::ResponseInputItem; +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; + +const NAMESPACE: &str = "clock"; +const TOOL_NAME: &str = "curr_time"; + +struct CurrentTimeOutput(CurrentTimeReminder); + +impl ToolOutput for CurrentTimeOutput { + fn log_preview(&self) -> String { + self.0.render() + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + FunctionToolOutput::from_text(self.0.render(), Some(true)) + .to_response_item(call_id, payload) + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + json!({ + "current_time": self.0.formatted_time(), + }) + } +} + +pub struct CurrentTimeHandler; + +impl ToolExecutor for CurrentTimeHandler { + fn tool_name(&self) -> ToolName { + ToolName::namespaced(NAMESPACE, TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + ToolSpec::Namespace(ResponsesApiNamespace { + name: NAMESPACE.to_string(), + description: "Tools for reading the current time.".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: TOOL_NAME.to_string(), + description: "Return the current time in UTC.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ Some(false.into()), + ), + output_schema: Some(json!({ + "type": "object", + "properties": { + "current_time": { + "type": "string", + "description": "Current UTC time formatted as YYYY-MM-DD HH:MM:SS UTC." + } + }, + "required": ["current_time"], + "additionalProperties": false + })), + })], + }) + } + + fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { + Box::pin(async move { + if !matches!(invocation.payload, ToolPayload::Function { .. }) { + return Err(FunctionCallError::RespondToModel(format!( + "{TOOL_NAME} handler received unsupported payload" + ))); + } + + let current_time = invocation + .session + .services + .time_provider + .current_time(invocation.session.thread_id) + .await + .map_err(|err| { + FunctionCallError::Fatal(format!("failed to read current time: {err:#}")) + })?; + Ok(boxed_tool_output(CurrentTimeOutput( + CurrentTimeReminder::new(current_time), + ))) + }) + } +} + +impl CoreToolRuntime for CurrentTimeHandler {} diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 401278576baf..28af3434ff12 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -56,7 +56,9 @@ impl DynamicToolHandler { namespace.map(|namespace| namespace.name.clone()), tool.name.clone(), ); - let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + let mut output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + // Exposure controls deferral; tool search restores this marker for deferred results. + output_tool.defer_loading = None; let spec = match namespace { Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { name: namespace.name.clone(), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index f0522c7c1763..808a9c123d6c 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -114,10 +114,15 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); let mut environments = Vec::with_capacity(invocation.turn.environments.turn_environments.len()); for environment in &invocation.turn.environments.turn_environments { + // TODO(anp): Migrate extension ToolEnvironment and granted-permission lookup to PathUri + // so extensions can receive foreign environment cwd values. + let Ok(native_cwd) = environment.cwd().to_abs_path() else { + continue; + }; let additional_permissions = apply_granted_turn_permissions( invocation.session.as_ref(), &environment.environment_id, - environment.cwd().as_path(), + native_cwd.as_path(), SandboxPermissions::UseDefault, /*additional_permissions*/ None, ) @@ -125,10 +130,10 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .additional_permissions; let file_system_sandbox_context = invocation .turn - .file_system_sandbox_context(additional_permissions, environment.cwd_uri()); + .file_system_sandbox_context(additional_permissions, environment.cwd()); environments.push(ToolEnvironment { environment_id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), + cwd: native_cwd, file_system: environment.environment.get_filesystem(), file_system_sandbox_context, }); @@ -138,7 +143,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), model: invocation.turn.model_info.slug.clone(), - truncation_policy: invocation.turn.truncation_policy, + truncation_policy: invocation.turn.model_info.truncation_policy.into(), conversation_history, turn_item_emitter: Arc::new(CoreTurnItemEmitter { session: Arc::downgrade(&invocation.session), @@ -310,12 +315,12 @@ mod tests { let weak_turn = Arc::downgrade(&turn); let turn_id = turn.sub_id.clone(); let model = turn.model_info.slug.clone(); - let truncation_policy = turn.truncation_policy; + let truncation_policy = turn.model_info.truncation_policy.into(); let expected_sandbox_cwds = turn .environments .turn_environments .iter() - .map(|environment| Some(environment.cwd_uri().clone())) + .map(|environment| Some(environment.cwd().clone())) .collect::>(); let history_item = ResponseItem::Message { id: None, @@ -324,7 +329,7 @@ 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)) diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 3b5ca644411e..c0173e46559f 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -160,7 +160,7 @@ impl McpHandler { tool_input: result.tool_input, wall_time: started.elapsed(), original_image_detail_supported: can_request_original_image_detail(&turn.model_info), - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), })) } } diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index e2f83859b693..231bdf92a98c 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::items::McpToolCallError; use codex_protocol::items::McpToolCallItem; use codex_protocol::items::McpToolCallStatus; @@ -33,6 +34,23 @@ pub use list_mcp_resource_templates::ListMcpResourceTemplatesHandler; pub use list_mcp_resources::ListMcpResourcesHandler; pub use read_mcp_resource::ReadMcpResourceHandler; +fn model_can_access_mcp_server(turn: &TurnContext, server: &str) -> bool { + turn.config.orchestrator_mcp_enabled || server != CODEX_APPS_MCP_SERVER_NAME +} + +fn ensure_model_can_access_mcp_server( + turn: &TurnContext, + server: &str, +) -> Result<(), FunctionCallError> { + if model_can_access_mcp_server(turn, server) { + Ok(()) + } else { + Err(FunctionCallError::RespondToModel(format!( + "MCP server '{server}' is disabled by `orchestrator.mcp.enabled`" + ))) + } +} + #[derive(Debug, Deserialize, Default)] struct ListResourcesArgs { /// Lists all resources from all servers if not specified. @@ -203,7 +221,9 @@ async fn emit_tool_call_begin( server, tool, arguments: arguments.unwrap_or(Value::Null), + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, status: McpToolCallStatus::InProgress, result: None, @@ -242,7 +262,9 @@ async fn emit_tool_call_end( server, tool, arguments: arguments.unwrap_or(Value::Null), + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, status, result, diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index 80b183dababc..ce762a0f021b 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -19,6 +19,8 @@ use super::ListResourceTemplatesPayload; use super::call_tool_result_from_content; use super::emit_tool_call_begin; use super::emit_tool_call_end; +use super::ensure_model_can_access_mcp_server; +use super::model_can_access_mcp_server; use super::normalize_optional_string; use super::parse_args_with_default; use super::parse_arguments; @@ -83,6 +85,7 @@ impl ListMcpResourceTemplatesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { + ensure_model_can_access_mcp_server(turn.as_ref(), &server_name)?; let params = cursor .clone() .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); @@ -109,15 +112,18 @@ impl ListMcpResourceTemplatesHandler { .services .mcp_connection_manager .load_full() - .list_all_resource_templates() + .list_all_resource_templates(|server_name| { + model_can_access_mcp_server(turn.as_ref(), server_name) + }) .await; Ok(ListResourceTemplatesPayload::from_all_servers(templates)) } } .await; + let truncation_policy = turn.model_info.truncation_policy.into(); match payload_result { - Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) { + Ok(payload) => match serialize_function_output(payload, truncation_policy) { Ok(output) => { let content = function_call_output_content_items_to_text(&output.body) .unwrap_or_default(); diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index a77d617b942b..564fb440fda8 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -19,6 +19,8 @@ use super::ListResourcesPayload; use super::call_tool_result_from_content; use super::emit_tool_call_begin; use super::emit_tool_call_end; +use super::ensure_model_can_access_mcp_server; +use super::model_can_access_mcp_server; use super::normalize_optional_string; use super::parse_args_with_default; use super::parse_arguments; @@ -83,6 +85,7 @@ impl ListMcpResourcesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { + ensure_model_can_access_mcp_server(turn.as_ref(), &server_name)?; let params = cursor .clone() .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); @@ -107,15 +110,18 @@ impl ListMcpResourcesHandler { .services .mcp_connection_manager .load_full() - .list_all_resources() + .list_all_resources(|server_name| { + model_can_access_mcp_server(turn.as_ref(), server_name) + }) .await; Ok(ListResourcesPayload::from_all_servers(resources)) } } .await; + let truncation_policy = turn.model_info.truncation_policy.into(); match payload_result { - Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) { + Ok(payload) => match serialize_function_output(payload, truncation_policy) { Ok(output) => { let content = function_call_output_content_items_to_text(&output.body) .unwrap_or_default(); diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index 9ca15718a0ce..3909011eb0a2 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -19,6 +19,7 @@ use super::ReadResourcePayload; use super::call_tool_result_from_content; use super::emit_tool_call_begin; use super::emit_tool_call_end; +use super::ensure_model_can_access_mcp_server; use super::normalize_required_string; use super::parse_args; use super::parse_arguments; @@ -82,6 +83,7 @@ impl ReadMcpResourceHandler { let start = Instant::now(); let payload_result: Result = async { + ensure_model_can_access_mcp_server(turn.as_ref(), &server)?; let result = session .read_resource(&server, ReadResourceRequestParams::new(uri.clone())) .await @@ -96,9 +98,10 @@ impl ReadMcpResourceHandler { }) } .await; + let truncation_policy = turn.model_info.truncation_policy.into(); match payload_result { - Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) { + Ok(payload) => match serialize_function_output(payload, truncation_policy) { Ok(output) => { let content = function_call_output_content_items_to_text(&output.body) .unwrap_or_default(); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 087b10d39c3b..ac95789f4c55 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod agent_jobs; pub(crate) mod agent_jobs_spec; pub(crate) mod apply_patch; pub(crate) mod apply_patch_spec; +mod current_time; mod dynamic; pub(crate) mod extension_tools; mod get_context_remaining; @@ -55,6 +56,7 @@ pub(crate) use crate::tools::code_mode::CodeModeWaitHandler; pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; +pub use current_time::CurrentTimeHandler; pub use dynamic::DynamicToolHandler; pub use get_context_remaining::GetContextRemainingHandler; pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 4c64e2cdb6f3..d849a03bc2cb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -147,6 +147,7 @@ async fn handle_spawn_agent( fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), + initial_multi_agent_mode: None, }, )) .await diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 595889215241..8f91ce5b8ee2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -228,7 +228,6 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result, 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 1ef93b77f036..0228f7acb929 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 8c3ea14325ac..2561cd7dedca 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -5,7 +5,7 @@ use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::function_tool::FunctionCallError; use crate::init_state_db; use crate::session::tests::make_session_and_context; -use crate::session_prefix::format_subagent_notification_message; +use crate::session_prefix::format_inter_agent_completion_message; use crate::thread_manager::thread_store_from_config; use crate::tools::context::ToolOutput; use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2; @@ -24,6 +24,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::BaseInstructions; @@ -1147,6 +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"); + turn.multi_agent_mode = MultiAgentMode::Proactive; set_turn_config(&mut turn, config); let session = Arc::new(session); @@ -1185,6 +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, MultiAgentMode::Proactive); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id && matches!( @@ -2027,6 +2030,18 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() .await .expect("followup_task should succeed"); + assert!(manager.captured_ops().iter().any(|(id, op)| { + *id == agent_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient == worker_path + && communication.encrypted_content.as_deref() == Some("continue") + && communication.trigger_turn + ) + })); + let second_turn = thread.codex.session.new_default_turn().await; thread .codex @@ -2043,14 +2058,18 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() ) .await; - let first_notification = format_subagent_notification_message( - worker_path.as_str(), + let first_notification = format_inter_agent_completion_message( + AgentPath::root(), + worker_path.clone(), &AgentStatus::Completed(Some("first done".to_string())), - ); - let second_notification = format_subagent_notification_message( - worker_path.as_str(), + ) + .expect("completed status should render"); + let second_notification = format_inter_agent_completion_message( + AgentPath::root(), + worker_path.clone(), &AgentStatus::Completed(Some("second done".to_string())), - ); + ) + .expect("completed status should render"); let notifications = timeout(Duration::from_secs(5), async { loop { @@ -2797,10 +2816,11 @@ 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, + /*supports_openai_form_elicitation*/ false, ) .await .expect("start thread"); @@ -4247,6 +4267,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr state_db.clone(), "11111111-1111-4111-8111-111111111111".to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let parent = manager @@ -4463,17 +4484,19 @@ async fn build_agent_spawn_config_uses_turn_context_values() { text: "base".to_string(), }; turn.developer_instructions = Some("dev".to_string()); - turn.compact_prompt = Some("compact".to_string()); - turn.shell_environment_policy = ShellEnvironmentPolicy { + let mut config = (*turn.config).clone(); + config.compact_prompt = Some("compact".to_string()); + config.permissions.shell_environment_policy = ShellEnvironmentPolicy { use_profile: true, ..ShellEnvironmentPolicy::default() }; + config.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); + turn.config = Arc::new(config); let temp_dir = tempfile::tempdir().expect("temp dir"); #[allow(deprecated)] { turn.cwd = temp_dir.abs(); } - turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); #[allow(deprecated)] let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( @@ -4502,9 +4525,6 @@ async fn build_agent_spawn_config_uses_turn_context_values() { expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); - expected.compact_prompt = turn.compact_prompt.clone(); - expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); - expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); #[allow(deprecated)] { expected.cwd = turn.cwd.clone(); @@ -4540,9 +4560,6 @@ async fn build_agent_resume_config_clears_base_instructions() { expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); - expected.compact_prompt = turn.compact_prompt.clone(); - expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); - expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); #[allow(deprecated)] { expected.cwd = turn.cwd.clone(); 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 59721268cfb1..0f1265cd934f 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 @@ -51,6 +51,11 @@ async fn handle_spawn_agent( let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; let fork_mode = args.fork_mode()?; + let multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( + turn.multi_agent_version, + &turn.session_source, + turn.multi_agent_mode, + ); let role_name = args .agent_type .as_deref() @@ -150,6 +155,7 @@ async fn handle_spawn_agent( fork_mode, parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), + initial_multi_agent_mode: multi_agent_mode, }, ), ) diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 53341b14f5a6..afc15f965972 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -70,8 +70,16 @@ impl RequestPermissionsHandler { "request_permissions requires a primary environment".to_string(), )); }; + // TODO(anp): Migrate request_permissions parsing and permission profiles to PathUri so + // environment-native foreign paths do not require host conversion. + let native_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "request_permissions cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let mut args: RequestPermissionsArgs = - parse_arguments_with_base_path(&arguments, turn_environment.cwd())?; + parse_arguments_with_base_path(&arguments, &native_cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 55906185abb4..ff7b421fd6cd 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -22,6 +22,7 @@ use codex_tools::build_request_plugin_install_elicitation_request; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_tools::verified_connector_install_completed; use rmcp::model::RequestId; +use serde::Deserialize; use serde_json::Value; use tracing::warn; @@ -37,14 +38,29 @@ use crate::tools::handlers::parse_arguments; use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; +use crate::tools::router::ToolSuggestPresentation; + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct RecommendedPluginInstallArgs { + #[serde(alias = "tool_id")] + plugin_id: String, + suggest_reason: String, +} pub struct RequestPluginInstallHandler { discoverable_tools: Vec, + presentation: ToolSuggestPresentation, } impl RequestPluginInstallHandler { - pub(crate) fn new(discoverable_tools: Vec) -> Self { - Self { discoverable_tools } + pub(crate) fn new( + discoverable_tools: Vec, + presentation: ToolSuggestPresentation, + ) -> Self { + Self { + discoverable_tools, + presentation, + } } } @@ -54,7 +70,7 @@ impl ToolExecutor for RequestPluginInstallHandler { } fn spec(&self) -> ToolSpec { - create_request_plugin_install_tool() + create_request_plugin_install_tool(self.presentation) } fn supports_parallel_tool_calls(&self) -> bool { @@ -88,20 +104,30 @@ impl RequestPluginInstallHandler { } }; - let args: RequestPluginInstallArgs = parse_arguments(&arguments)?; - let suggest_reason = args.suggest_reason.trim(); + let (requested_tool_id, requested_tool_type, suggest_reason) = match self.presentation { + ToolSuggestPresentation::ListTool => { + let args: RequestPluginInstallArgs = parse_arguments(&arguments)?; + if args.action_type != DiscoverableToolAction::Install { + return Err(FunctionCallError::RespondToModel( + "plugin install requests currently support only action_type=\"install\"" + .to_string(), + )); + } + (args.tool_id, Some(args.tool_type), args.suggest_reason) + } + ToolSuggestPresentation::RecommendationContext => { + let args: RecommendedPluginInstallArgs = parse_arguments(&arguments)?; + (args.plugin_id, None, args.suggest_reason) + } + }; + let suggest_reason = suggest_reason.trim(); if suggest_reason.is_empty() { return Err(FunctionCallError::RespondToModel( "suggest_reason must not be empty".to_string(), )); } - if args.action_type != DiscoverableToolAction::Install { - return Err(FunctionCallError::RespondToModel( - "plugin install requests currently support only action_type=\"install\"" - .to_string(), - )); - } - if args.tool_type == DiscoverableToolType::Plugin + if (requested_tool_type == Some(DiscoverableToolType::Plugin) + || self.presentation == ToolSuggestPresentation::RecommendationContext) && turn.app_server_client_name.as_deref() == Some("codex-tui") { return Err(FunctionCallError::RespondToModel( @@ -116,19 +142,41 @@ impl RequestPluginInstallHandler { let tool = discoverable_tools .into_iter() - .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) + .find(|tool| { + tool.id() == requested_tool_id + && match self.presentation { + ToolSuggestPresentation::ListTool => { + Some(tool.tool_type()) == requested_tool_type + } + ToolSuggestPresentation::RecommendationContext => { + matches!(tool, DiscoverableTool::Plugin(_)) + } + } + }) .ok_or_else(|| { + let (argument_name, source) = match self.presentation { + ToolSuggestPresentation::ListTool => ( + "tool_id", + format!( + "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" + ), + ), + ToolSuggestPresentation::RecommendationContext => ( + "plugin_id", + "the entries in the list".to_string(), + ), + }; FunctionCallError::RespondToModel(format!( - "tool_id must match one of the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" + "{argument_name} must match one of {source}" )) })?; + let tool_type = tool.tool_type(); let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into()); let params = build_request_plugin_install_elicitation_request( CODEX_APPS_MCP_SERVER_NAME, session.thread_id.to_string(), turn.sub_id.clone(), - &args, suggest_reason, &tool, ); @@ -157,7 +205,7 @@ impl RequestPluginInstallHandler { } if elicitation.sent { - let tool_type = match args.tool_type { + let tool_type = match tool_type { DiscoverableToolType::Connector => "connector", DiscoverableToolType::Plugin => "plugin", }; @@ -180,8 +228,8 @@ impl RequestPluginInstallHandler { let content = serde_json::to_string(&RequestPluginInstallResult { completed, user_confirmed, - tool_type: args.tool_type, - action_type: args.action_type, + tool_type, + action_type: DiscoverableToolAction::Install, tool_id: tool.id().to_string(), tool_name: tool.name().to_string(), suggest_reason: suggest_reason.to_string(), @@ -280,7 +328,27 @@ async fn verify_request_plugin_install_completed( }), DiscoverableTool::Plugin(plugin) => { if is_remote_plugin_install_suggestion(&plugin.id) { - return true; + let (_, accessible_connectors) = tokio::join!( + refresh_remote_installed_plugins_cache_after_install( + session, + turn, + auth, + plugin.id.as_str(), + ), + refresh_missing_requested_connectors( + session, + turn, + auth, + &plugin.app_connector_ids, + plugin.id.as_str(), + ) + ); + return accessible_connectors.is_some_and(|accessible_connectors| { + all_requested_connectors_picked_up( + &plugin.app_connector_ids, + &accessible_connectors, + ) + }); } session.reload_user_config_layer().await; @@ -303,6 +371,29 @@ async fn verify_request_plugin_install_completed( } } +async fn refresh_remote_installed_plugins_cache_after_install( + session: &crate::session::session::Session, + turn: &crate::session::turn_context::TurnContext, + auth: Option<&codex_login::CodexAuth>, + tool_id: &str, +) { + let plugins_manager = &session.services.plugins_manager; + let plugins_config = turn.config.plugins_config_input(); + if let Err(err) = plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + &plugins_config, + auth, + &[REMOTE_GLOBAL_MARKETPLACE_NAME], + /*on_effective_plugins_changed*/ None, + ) + .await + { + warn!( + "failed to refresh remote installed plugins cache after plugin install request for {tool_id}: {err:#}" + ); + } +} + fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool { plugin_id .rsplit_once('@') diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs index ed143283e74f..e37e223832c4 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs @@ -5,51 +5,76 @@ use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; use std::collections::BTreeMap; -pub(crate) fn create_request_plugin_install_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "tool_type".to_string(), - JsonSchema::string(Some( - "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." - .to_string(), - )), - ), - ( - "action_type".to_string(), - JsonSchema::string(Some("Suggested action for the tool. Use \"install\".".to_string())), - ), - ( - "tool_id".to_string(), - JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())), +use crate::tools::router::ToolSuggestPresentation; + +pub(crate) fn create_request_plugin_install_tool( + presentation: ToolSuggestPresentation, +) -> ToolSpec { + let (properties, required, description) = match presentation { + ToolSuggestPresentation::ListTool => ( + BTreeMap::from([ + ( + "tool_type".to_string(), + JsonSchema::string(Some( + "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." + .to_string(), + )), + ), + ( + "action_type".to_string(), + JsonSchema::string(Some( + "Suggested action for the tool. Use \"install\".".to_string(), + )), + ), + ( + "tool_id".to_string(), + JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())), + ), + ( + "suggest_reason".to_string(), + JsonSchema::string(Some( + "Concise one-line user-facing reason why this plugin or connector can help with the current request." + .to_string(), + )), + ), + ]), + vec![ + "tool_type".to_string(), + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ], + format!( + "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + ), ), - ( - "suggest_reason".to_string(), - JsonSchema::string(Some( - "Concise one-line user-facing reason why this plugin or connector can help with the current request." - .to_string(), - )), + ToolSuggestPresentation::RecommendationContext => ( + BTreeMap::from([ + ( + "plugin_id".to_string(), + JsonSchema::string(Some( + "Plugin id from the `` list.".to_string(), + )), + ), + ( + "suggest_reason".to_string(), + JsonSchema::string(Some( + "Concise one-line user-facing reason why this plugin can help with the current request." + .to_string(), + )), + ), + ]), + vec!["plugin_id".to_string(), "suggest_reason".to_string()], + "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), ), - ]); - - let description = format!( - "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." - ); + }; ToolSpec::Function(ResponsesApiTool { name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(), description, strict: false, defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ]), - Some(false.into()), - ), + parameters: JsonSchema::object(properties, Some(required), Some(false.into())), output_schema: None, }) } @@ -62,7 +87,7 @@ mod tests { use std::collections::BTreeMap; #[test] - fn create_request_plugin_install_tool_uses_expected_wire_shape() { + fn create_request_plugin_install_tool_uses_expected_legacy_wire_shape() { let expected_description = concat!( "# Request plugin/connector install\n\n", "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request.\n\n", @@ -71,7 +96,7 @@ mod tests { ); assert_eq!( - create_request_plugin_install_tool(), + create_request_plugin_install_tool(ToolSuggestPresentation::ListTool), ToolSpec::Function(ResponsesApiTool { name: "request_plugin_install".to_string(), description: expected_description.to_string(), @@ -116,4 +141,37 @@ mod tests { }) ); } + + #[test] + fn recommendation_context_uses_simplified_plugin_wire_shape() { + assert_eq!( + create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext), + ToolSpec::Function(ResponsesApiTool { + name: "request_plugin_install".to_string(), + description: "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::from([ + ( + "plugin_id".to_string(), + JsonSchema::string(Some( + "Plugin id from the `` list.".to_string(), + )), + ), + ( + "suggest_reason".to_string(), + JsonSchema::string(Some( + "Concise one-line user-facing reason why this plugin can help with the current request." + .to_string(), + )), + ), + ]), + Some(vec!["plugin_id".to_string(), "suggest_reason".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) + ); + } } diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs index cacc25ff9578..62c8afaa6553 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs @@ -68,6 +68,24 @@ fn remote_plugin_install_suggestions_skip_core_installed_verification() { assert!(!is_remote_plugin_install_suggestion("Plugin_123")); } +#[test] +fn recommended_plugin_install_args_accept_legacy_tool_id() { + let current: RecommendedPluginInstallArgs = serde_json::from_value(json!({ + "plugin_id": "google-drive@openai-curated-remote", + "suggest_reason": "Use Google Drive for this request" + })) + .expect("current arguments should deserialize"); + let legacy: RecommendedPluginInstallArgs = serde_json::from_value(json!({ + "tool_type": "plugin", + "action_type": "install", + "tool_id": "google-drive@openai-curated-remote", + "suggest_reason": "Use Google Drive for this request" + })) + .expect("legacy arguments should deserialize"); + + assert_eq!(current, legacy); +} + #[test] fn request_plugin_install_response_persists_only_decline_always_mode() { assert!(request_plugin_install_response_requests_persistent_disable( diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 91f2c64d3c3a..131bcdba95de 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -26,6 +26,7 @@ use crate::tools::sandboxing::ToolCtx; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; +use codex_utils_path_uri::PathUri; mod shell_command; @@ -80,7 +81,12 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result Result, @@ -33,6 +34,7 @@ pub(crate) struct ToolSearchHandlerCache { } impl ToolSearchHandlerCache { + #[instrument(level = "trace", skip_all, fields(search_info_count = search_infos.len()))] pub(crate) fn get_or_build(&self, search_infos: Vec) -> Arc { { let cached = self.cached(); @@ -64,6 +66,11 @@ impl ToolSearchHandlerCache { } impl ToolSearchHandler { + #[instrument( + level = "trace", + skip_all, + fields(search_info_count = search_infos.len()) + )] pub(crate) fn new(search_infos: Vec) -> Self { let search_source_infos = search_infos .iter() diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 0d3ccb9d0494..421d5678bd97 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::sync::Arc; use crate::function_tool::FunctionCallError; @@ -30,9 +31,14 @@ use crate::unified_exec::generate_chunk_id; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; +use codex_shell_command::shell_detect::detect_shell_type; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_output_truncation::approx_token_count; +use codex_utils_path_uri::PathConvention; use super::super::shell_spec::CommandToolOptions; use super::super::shell_spec::create_exec_command_tool_with_environment_id; @@ -131,26 +137,68 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; + let native_environment_cwd = turn_environment.cwd().clone(); let cwd = environment_args .workdir .as_deref() .filter(|workdir| !workdir.is_empty()) .map_or_else( - || turn_environment.cwd().clone(), - |workdir| turn_environment.cwd().join(workdir), - ); + || Ok(native_environment_cwd.clone()), + |workdir| native_environment_cwd.join(workdir), + ) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); - let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; + + // A foreign cwd cannot seed the AbsolutePathBufGuard used to resolve relative paths in the + // permissions config below. Consult the configured platform-sandbox requirement before + // deciding whether parsing may continue without that base path. + let sandbox = SandboxManager::new().select_initial( + &turn.file_system_sandbox_policy(), + turn.network_sandbox_policy(), + SandboxablePreference::Auto, + turn.windows_sandbox_level, + turn.network.is_some(), + ); + // `to_abs_path()` alone cannot identify foreign drive paths: `file:///C:/repo` is + // representable as `/C:/repo` on POSIX. Require the inferred convention to match too. + let cwd_uses_native_convention = + cwd.infer_path_convention() == Some(PathConvention::native()); + // TODO(anp): Remove this parsing split once sandboxing supports foreign paths. + let native_cwd = match cwd.to_abs_path() { + Ok(cwd) if cwd_uses_native_convention => Some(cwd), + _ if sandbox == SandboxType::None => None, + Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())), + Ok(_) => { + return Err(FunctionCallError::RespondToModel(format!( + "path URI `{cwd}` does not use the host's native {} path convention", + PathConvention::native() + ))); + } + }; + let mut args: ExecCommandArgs = match native_cwd.as_ref() { + Some(native_cwd) => { + // The base path only resolves paths nested in the permissions config types. + parse_arguments_with_base_path(&arguments, native_cwd)? + } + None => { + // Parsing without a base only skips relative-path resolution inside the + // permissions config. That is safe only for a truly unsandboxed attempt; + // sandboxed attempts fall through and return the conversion error below. + parse_arguments(&arguments)? + } + }; let hook_command = args.cmd.clone(); - maybe_emit_implicit_skill_invocation( - session.as_ref(), - context.turn.as_ref(), - &hook_command, - &cwd, - ) - .await; - let process_id = manager.allocate_process_id().await; + // TODO(anp) wire PathUri through implicit skills instead of skipping on foreign paths + if let Some(native_cwd) = native_cwd.as_ref() { + maybe_emit_implicit_skill_invocation( + session.as_ref(), + context.turn.as_ref(), + &hook_command, + native_cwd, + ) + .await; + } let shell_mode = shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref()); // Remote environments may use a different OS and must build commands with their native @@ -160,6 +208,26 @@ impl ExecCommandHandler { .clone() .map(Arc::new) .unwrap_or_else(|| session.user_shell()); + // TODO(anp): Resolve requested shells in remote environments instead of restricting + // commands to the reported default shell. + if environment.is_remote() + && let Some(requested_shell) = args.shell.take() + { + let Some(remote_shell) = turn_environment.shell.as_ref() else { + return Err(FunctionCallError::RespondToModel(format!( + "environment `{}` does not report a shell", + turn_environment.environment_id + ))); + }; + if detect_shell_type(Path::new(&requested_shell)) != Some(remote_shell.shell_type) { + return Err(FunctionCallError::RespondToModel(format!( + "environment `{}` only supports `{}`", + turn_environment.environment_id, + remote_shell.name() + ))); + } + } + let process_id = manager.allocate_process_id().await; let resolved_command = get_command( &args, shell, @@ -185,10 +253,12 @@ impl ExecCommandHandler { let exec_permission_approvals_enabled = session.features().enabled(Feature::ExecPermissionApprovals); let requested_additional_permissions = additional_permissions.clone(); + // TODO(anp): Make permission matching operate on PathUri for remote environments. + let permission_cwd = native_cwd.as_ref().unwrap_or(&turn.config.cwd); let effective_additional_permissions = apply_granted_turn_permissions( context.session.as_ref(), &turn_environment.environment_id, - cwd.as_path(), + permission_cwd.as_path(), sandbox_permissions, additional_permissions, ) @@ -228,7 +298,7 @@ impl ExecCommandHandler { effective_additional_permissions.sandbox_permissions, effective_additional_permissions.additional_permissions, effective_additional_permissions.permissions_preapproved, - &cwd, + permission_cwd, ) }, |permissions| Ok(Some(permissions)), @@ -260,7 +330,7 @@ impl ExecCommandHandler { wall_time: std::time::Duration::ZERO, raw_output: output.into_text().into_bytes(), compaction: None, - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), max_output_tokens, process_id: None, exit_code: None, @@ -280,7 +350,7 @@ impl ExecCommandHandler { yield_time_ms, max_output_tokens, cwd, - sandbox_cwd: turn_environment.cwd().clone(), + sandbox_cwd: native_environment_cwd, turn_environment: turn_environment.clone(), shell_mode, network: context.turn.network.clone(), @@ -302,7 +372,12 @@ impl ExecCommandHandler { let original_token_count = approx_token_count(&output_text); let chunk_id = generate_chunk_id(); let command = [hook_command.clone()]; - let compaction = if turn.features.enabled(Feature::ExecOutputCompaction) { + let exec_output_compaction_enabled = turn + .config + .features + .get() + .enabled(Feature::ExecOutputCompaction); + let compaction = if exec_output_compaction_enabled { compact_exec_output_for_turn(ExecOutputCompactionTurnRequest { session: context.session.as_ref(), turn: turn.as_ref(), @@ -311,13 +386,13 @@ impl ExecCommandHandler { command: &command, output: output_text.as_str(), max_output_tokens, - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), }) .await } else { None }; - if turn.features.enabled(Feature::ExecOutputCompaction) { + if exec_output_compaction_enabled { manager .archive_completed_output(chunk_id.as_str(), output_text.as_bytes()) .await; @@ -328,7 +403,7 @@ impl ExecCommandHandler { wall_time: output.duration, raw_output: output_text.into_bytes(), compaction, - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), max_output_tokens, // Sandbox denial is terminal, so there is no live // process for write_stdin to resume. diff --git a/codex-rs/core/src/tools/handlers/unified_exec/read_exec_output.rs b/codex-rs/core/src/tools/handlers/unified_exec/read_exec_output.rs index 369d4fc7ddda..b716ec321407 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/read_exec_output.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/read_exec_output.rs @@ -99,8 +99,9 @@ impl ToolExecutor for ReadExecOutputHandler { } else { slice_output(raw_text.as_str(), args.line_start, args.line_count) }; - let max_tokens = resolve_max_tokens(args.max_output_tokens) - .min(turn.truncation_policy.token_budget()); + let truncation_policy: TruncationPolicy = turn.model_info.truncation_policy.into(); + let max_tokens = + resolve_max_tokens(args.max_output_tokens).min(truncation_policy.token_budget()); let output = formatted_truncate_text(&selected, TruncationPolicy::Tokens(max_tokens)); let text = format!("Chunk ID: {}\nOutput:\n{output}", args.chunk_id); diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 8ddce6584025..0b1f9d10c1d0 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -75,7 +75,7 @@ impl WriteStdinHandler { input: &args.chars, yield_time_ms: args.yield_time_ms, max_output_tokens: args.max_output_tokens, - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), }) .await .map_err(|err| { diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index dc52e7e336c7..09a6844f19c2 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; @@ -143,11 +140,18 @@ impl ViewImageHandler { "view_image is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + // TODO(anp): Resolve tool paths using the selected environment's native path convention + // so view_image can support relative paths in foreign environments. + let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "environment cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let abs_path = cwd.join(path); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, - turn_environment.cwd_uri(), + turn_environment.cwd(), ); let fs = turn_environment.environment.get_filesystem(); let path_uri = PathUri::from_abs_path(&abs_path); @@ -188,24 +192,8 @@ impl ViewImageHandler { DEFAULT_IMAGE_DETAIL }; - let image_url = if turn.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, @@ -272,6 +260,7 @@ mod tests { use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde_json::json; @@ -288,7 +277,7 @@ mod tests { turn.environments.turn_environments[0] = TurnEnvironment::new( current.environment_id, current.environment, - cwd, + PathUri::from_abs_path(&cwd), current.shell, ); } @@ -412,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/hosted_spec.rs b/codex-rs/core/src/tools/hosted_spec.rs index ba26ba6b2a53..24a7905fa6eb 100644 --- a/codex-rs/core/src/tools/hosted_spec.rs +++ b/codex-rs/core/src/tools/hosted_spec.rs @@ -18,11 +18,12 @@ pub fn create_image_generation_tool(output_format: &str) -> ToolSpec { } pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option { - let external_web_access = match options.web_search_mode { - Some(WebSearchMode::Cached) => Some(false), - Some(WebSearchMode::Live) => Some(true), - Some(WebSearchMode::Disabled) | None => None, - }?; + let (external_web_access, index_gated_web_access) = match options.web_search_mode { + Some(WebSearchMode::Cached) => (false, None), + Some(WebSearchMode::Indexed) => (true, Some(true)), + Some(WebSearchMode::Live) => (true, None), + Some(WebSearchMode::Disabled) | None => return None, + }; let search_content_types = match options.web_search_tool_type { WebSearchToolType::Text => None, @@ -36,6 +37,7 @@ pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option ToolMode { + turn_context.model_info.tool_mode.unwrap_or_else(|| { + if turn_context.config.features.enabled(Feature::CodeModeOnly) { + ToolMode::CodeModeOnly + } else if turn_context.config.features.enabled(Feature::CodeMode) { + ToolMode::CodeMode + } else { + ToolMode::Direct + } + }) +} + /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. pub fn format_exec_output_for_model( diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9cc808a5b690..2b63164b4df4 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -51,6 +51,7 @@ pub(crate) struct NetworkApprovalSpec { pub mode: NetworkApprovalMode, pub trigger: GuardianNetworkAccessTrigger, pub command: String, + pub environment_id: String, } #[derive(Clone, Debug)] @@ -120,14 +121,20 @@ impl ActiveNetworkApproval { #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct HostApprovalKey { + environment_id: String, host: String, protocol: &'static str, port: u16, } impl HostApprovalKey { - fn from_request(request: &NetworkPolicyRequest, protocol: NetworkApprovalProtocol) -> Self { + fn from_request( + request: &NetworkPolicyRequest, + protocol: NetworkApprovalProtocol, + environment_id: String, + ) -> Self { Self { + environment_id, host: request.host.to_ascii_lowercase(), protocol: protocol_key_label(protocol), port: request.port, @@ -224,9 +231,16 @@ struct ActiveNetworkApprovalCall { turn_id: String, trigger: GuardianNetworkAccessTrigger, command: String, + environment_id: String, cancellation_token: CancellationToken, } +enum ActiveNetworkApprovalAttribution { + None, + Single(Arc), + Ambiguous, +} + #[derive(Default)] struct NetworkApprovalCallState { active_calls: IndexMap>, @@ -267,6 +281,7 @@ impl NetworkApprovalService { turn_id: String, trigger: GuardianNetworkAccessTrigger, command: String, + environment_id: String, cancellation_token: CancellationToken, ) { let mut calls = self.calls.lock().await; @@ -278,6 +293,7 @@ impl NetworkApprovalService { turn_id, trigger, command, + environment_id, cancellation_token, }), ); @@ -299,6 +315,18 @@ impl NetworkApprovalService { None } + async fn resolve_active_call_attribution(&self) -> ActiveNetworkApprovalAttribution { + let calls = self.calls.lock().await; + match calls.active_calls.len() { + 0 => ActiveNetworkApprovalAttribution::None, + 1 => calls.active_calls.values().next().cloned().map_or( + ActiveNetworkApprovalAttribution::None, + ActiveNetworkApprovalAttribution::Single, + ), + _ => ActiveNetworkApprovalAttribution::Ambiguous, + } + } + async fn get_or_create_pending_approval( &self, key: HostApprovalKey, @@ -384,7 +412,10 @@ impl NetworkApprovalService { } fn approval_id_for_key(key: &HostApprovalKey) -> String { - format!("network#{}#{}#{}", key.protocol, key.host, key.port) + format!( + "network#{}#{}#{}#{}", + key.environment_id, key.protocol, key.host, key.port + ) } pub(crate) async fn handle_inline_policy_request( @@ -400,7 +431,38 @@ impl NetworkApprovalService { NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, }; - let key = HostApprovalKey::from_request(&request, protocol); + let (owner_call, active_environment_id) = + if let Some(environment_id) = request.environment_id.clone() { + let owner_call = match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::Single(call) => { + (call.environment_id == environment_id).then_some(call) + } + ActiveNetworkApprovalAttribution::None + | ActiveNetworkApprovalAttribution::Ambiguous => None, + }; + (owner_call, Some(environment_id)) + } else { + match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::None => (None, None), + ActiveNetworkApprovalAttribution::Single(call) => { + let environment_id = call.environment_id.clone(); + (Some(call), Some(environment_id)) + } + ActiveNetworkApprovalAttribution::Ambiguous => { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + } + } + }; + let turn_context = Self::active_turn_context(session.as_ref()).await; + let Some(environment_id) = active_environment_id.or_else(|| { + turn_context + .as_ref() + .and_then(|turn_context| turn_context.environments.primary()) + .map(|environment| environment.environment_id.clone()) + }) else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; + let key = HostApprovalKey::from_request(&request, protocol, environment_id.clone()); { let denied_hosts = self.session_denied_hosts.lock().await; @@ -426,35 +488,43 @@ impl NetworkApprovalService { format!("Network access to \"{target}\" was blocked by policy."); let prompt_reason = format!("{} is not in the allowed_domains", request.host); - let Some(turn_context) = Self::active_turn_context(session.as_ref()).await else { + let Some(turn_context) = turn_context else { pending.set_decision(PendingApprovalDecision::Deny).await; self.pending_host_approvals.lock().await.remove(&key); - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( - policy_denial_message, - )) - .await; + if let Some(owner_call) = owner_call.as_ref() { + self.record_call_outcome( + &owner_call.registration_id, + NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message), + ) + .await; + } return NetworkDecision::deny(REASON_NOT_ALLOWED); }; if !permission_profile_allows_network_approval_flow(&turn_context.permission_profile()) { pending.set_decision(PendingApprovalDecision::Deny).await; self.pending_host_approvals.lock().await.remove(&key); - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( - policy_denial_message, - )) - .await; + if let Some(owner_call) = owner_call.as_ref() { + self.record_call_outcome( + &owner_call.registration_id, + NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message), + ) + .await; + } return NetworkDecision::deny(REASON_NOT_ALLOWED); } if !allows_network_approval_flow(turn_context.approval_policy.value()) { pending.set_decision(PendingApprovalDecision::Deny).await; self.pending_host_approvals.lock().await.remove(&key); - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( - policy_denial_message, - )) - .await; + if let Some(owner_call) = owner_call.as_ref() { + self.record_call_outcome( + &owner_call.registration_id, + NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message), + ) + .await; + } return NetworkDecision::deny(REASON_NOT_ALLOWED); } - let owner_call = self.resolve_single_active_call().await; let network_approval_context = NetworkApprovalContext { host: request.host.clone(), protocol, @@ -519,14 +589,28 @@ impl NetworkApprovalService { .await } else { let available_decisions = None; + let cwd = if let Some(owner_call) = owner_call.as_ref() { + owner_call.trigger.cwd.clone() + } else { + turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id) + .and_then(|environment| environment.cwd().to_abs_path().ok()) + .unwrap_or_else(|| { + #[allow(deprecated)] + turn_context.cwd.clone() + }) + }; session .request_command_approval( turn_context.as_ref(), guardian_approval_id, /*approval_id*/ None, + Some(environment_id), prompt_command, - #[allow(deprecated)] - turn_context.cwd.clone(), + cwd, Some(prompt_reason), Some(network_approval_context.clone()), /*proposed_execpolicy_amendment*/ None, @@ -711,6 +795,7 @@ pub(crate) async fn begin_network_approval( mode, trigger, command, + environment_id, } = spec?; if !managed_network_active || network.is_none() { return None; @@ -726,6 +811,7 @@ pub(crate) async fn begin_network_approval( turn_id.to_string(), trigger, command, + environment_id, cancellation_token.clone(), ) .await; diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 25bcfe255dc0..60c7215b1b0b 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken; async fn pending_approvals_are_deduped_per_host_protocol_and_port() { let service = NetworkApprovalService::default(); let key = HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "http", port: 443, @@ -30,11 +31,13 @@ async fn pending_approvals_are_deduped_per_host_protocol_and_port() { async fn pending_approvals_do_not_dedupe_across_ports() { let service = NetworkApprovalService::default(); let first_key = HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 443, }; let second_key = HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 8443, @@ -48,6 +51,56 @@ async fn pending_approvals_do_not_dedupe_across_ports() { assert!(!Arc::ptr_eq(&first, &second)); } +#[tokio::test] +async fn pending_approvals_do_not_dedupe_across_environments() { + let service = NetworkApprovalService::default(); + let first_key = HostApprovalKey { + environment_id: "local".to_string(), + host: "example.com".to_string(), + protocol: "https", + port: 443, + }; + let second_key = HostApprovalKey { + environment_id: "remote".to_string(), + ..first_key.clone() + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(first_key).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(second_key).await; + + assert!(first_is_owner); + assert!(second_is_owner); + assert!(!Arc::ptr_eq(&first, &second)); +} + +#[tokio::test] +async fn session_approved_hosts_are_scoped_by_environment() { + let service = NetworkApprovalService::default(); + let local_key = HostApprovalKey { + environment_id: "local".to_string(), + host: "example.com".to_string(), + protocol: "https", + port: 443, + }; + let remote_key = HostApprovalKey { + environment_id: "remote".to_string(), + ..local_key.clone() + }; + service + .session_approved_hosts + .lock() + .await + .insert(local_key); + + assert!( + !service + .session_approved_hosts + .lock() + .await + .contains(&remote_key) + ); +} + #[tokio::test] async fn session_approved_hosts_preserve_protocol_and_port_scope() { let source = NetworkApprovalService::default(); @@ -55,16 +108,19 @@ async fn session_approved_hosts_preserve_protocol_and_port_scope() { let mut approved_hosts = source.session_approved_hosts.lock().await; approved_hosts.extend([ HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 443, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 8443, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "http", port: 80, @@ -82,22 +138,32 @@ async fn session_approved_hosts_preserve_protocol_and_port_scope() { .iter() .cloned() .collect::>(); - copied.sort_by(|a, b| (&a.host, a.protocol, a.port).cmp(&(&b.host, b.protocol, b.port))); + copied.sort_by(|a, b| { + (&a.environment_id, &a.host, a.protocol, a.port).cmp(&( + &b.environment_id, + &b.host, + b.protocol, + b.port, + )) + }); assert_eq!( copied, vec![ HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "http", port: 80, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 443, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 8443, @@ -112,6 +178,7 @@ async fn sync_session_approved_hosts_to_replaces_existing_target_hosts() { { let mut approved_hosts = source.session_approved_hosts.lock().await; approved_hosts.insert(HostApprovalKey { + environment_id: "local".to_string(), host: "source.example.com".to_string(), protocol: "https", port: 443, @@ -122,6 +189,7 @@ async fn sync_session_approved_hosts_to_replaces_existing_target_hosts() { { let mut approved_hosts = target.session_approved_hosts.lock().await; approved_hosts.insert(HostApprovalKey { + environment_id: "local".to_string(), host: "stale.example.com".to_string(), protocol: "https", port: 8443, @@ -141,6 +209,7 @@ async fn sync_session_approved_hosts_to_replaces_existing_target_hosts() { assert_eq!( copied, vec![HostApprovalKey { + environment_id: "local".to_string(), host: "source.example.com".to_string(), protocol: "https", port: 443, @@ -237,6 +306,7 @@ async fn register_call_with_default_shell_trigger( tty: None, }, "curl https://example.com".to_string(), + "local".to_string(), cancellation_token.clone(), ) .await; @@ -263,6 +333,7 @@ async fn active_call_preserves_triggering_command_context() { "turn-1".to_string(), expected.clone(), "curl https://example.com".to_string(), + "remote".to_string(), CancellationToken::new(), ) .await; @@ -274,6 +345,21 @@ async fn active_call_preserves_triggering_command_context() { assert_eq!(&call.trigger, &expected); assert_eq!(call.command, "curl https://example.com"); + assert_eq!(call.environment_id, "remote"); +} + +#[tokio::test] +async fn multiple_active_calls_are_ambiguous_even_in_the_same_environment() { + let service = NetworkApprovalService::default(); + register_call_with_default_shell_trigger(&service, "registration-1").await; + register_call_with_default_shell_trigger(&service, "registration-2").await; + + match service.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::Ambiguous => {} + ActiveNetworkApprovalAttribution::None | ActiveNetworkApprovalAttribution::Single(_) => { + panic!("multiple active calls should be ambiguous") + } + } } #[tokio::test] diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 7e97e4ad5144..aaa0dfe9d14a 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,31 +227,46 @@ 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.features.use_legacy_landlock(); + let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); #[allow(deprecated)] - let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); - let sandbox_policy_cwd = PathUri::from_abs_path(sandbox_cwd); + let sandbox_policy_cwd = tool + .sandbox_cwd(req) + .cloned() + .unwrap_or_else(|| PathUri::from_abs_path(&turn_ctx.cwd)); 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, workspace_roots: workspace_roots.as_slice(), - codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), + codex_linux_sandbox_exe: turn_ctx.config.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, windows_sandbox_private_desktop: turn_ctx @@ -399,25 +416,34 @@ 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 } else { - turn_ctx.codex_linux_sandbox_exe.as_ref() + turn_ctx.config.codex_linux_sandbox_exe.as_ref() }; 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/registry.rs b/codex-rs/core/src/tools/registry.rs index e4bea7bd30ab..bd4c8ac97f0a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -34,6 +34,7 @@ use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use futures::future::BoxFuture; use serde_json::Value; +use tracing::instrument; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; @@ -331,6 +332,7 @@ impl ToolRegistry { Self { tools } } + #[instrument(level = "trace", skip_all)] pub(crate) fn from_tools(tools: impl IntoIterator>) -> Self { let mut tools_by_name = HashMap::new(); for tool in tools { diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index f0339eddb565..14dbfa1e0ec8 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -69,11 +69,23 @@ pub struct ToolRouter { pub(crate) struct ToolRouterParams<'a> { pub(crate) mcp_tools: Option>, pub(crate) deferred_mcp_tools: Option>, - pub(crate) discoverable_tools: Option>, + pub(crate) tool_suggest_candidates: Option, pub(crate) extension_tool_executors: Vec>>, pub(crate) dynamic_tools: &'a [DynamicToolSpec], } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestPresentation { + ListTool, + RecommendationContext, +} + +#[derive(Clone, Debug)] +pub(crate) struct ToolSuggestCandidates { + pub(crate) tools: Vec, + pub(crate) presentation: ToolSuggestPresentation, +} + impl ToolRouter { pub(crate) fn from_turn_context( turn_context: &TurnContext, @@ -294,7 +306,7 @@ impl ToolRouter { source, result, } = input; - if !turn.features.enabled(Feature::ToolRouter) { + if !turn.config.features.get().enabled(Feature::ToolRouter) { return None; } @@ -471,6 +483,7 @@ fn tool_dialog_locator_json( locator.to_string() } +#[instrument(level = "trace", skip_all)] pub(crate) fn extension_tool_executors( session: &Session, ) -> Vec>> { diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index eb960511f425..973e8fc1c701 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -145,9 +145,9 @@ fn extension_echo_router(session: &Session, turn: &TurnContext) -> ToolRouter { ToolRouter::from_turn_context( turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: None, - discoverable_tools: None, extension_tool_executors: extension_tool_executors(session), dynamic_tools: turn.dynamic_tools.as_slice(), }, @@ -162,7 +162,7 @@ fn extension_echo_call(call_id: &str) -> anyhow::Result { namespace: Some("extension/".to_string()), arguments: json!({ "message": "hello" }).to_string(), call_id: call_id.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call")) } @@ -213,9 +213,9 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: Some(mcp_tools), - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, @@ -256,7 +256,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"); @@ -281,6 +281,7 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: Some(vec![ mcp_tool_info( @@ -296,7 +297,6 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { "query_with_delay", ), ]), - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, @@ -330,9 +330,9 @@ async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, @@ -385,9 +385,9 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: None, deferred_mcp_tools: None, mcp_tools: None, - discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: &dynamic_tools, }, @@ -439,7 +439,7 @@ 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)) @@ -520,7 +520,9 @@ async fn tool_router_disabled_preserves_output_and_skips_diagnostics() -> anyhow #[tokio::test] async fn tool_router_missing_state_db_still_returns_normal_output() -> anyhow::Result<()> { let (mut session, mut turn) = make_session_and_context().await; - turn.features.enable(Feature::ToolRouter)?; + Arc::make_mut(&mut turn.config) + .features + .enable(Feature::ToolRouter)?; session.services.extensions = extension_tool_test_registry(); let router = extension_echo_router(&session, &turn); @@ -546,7 +548,9 @@ async fn tool_router_records_direct_dispatch_diagnostics() -> anyhow::Result<()> codex_state::StateRuntime::init(state_home.path().to_path_buf(), "test".to_string()) .await?; let (mut session, mut turn) = make_session_and_context().await; - turn.features.enable(Feature::ToolRouter)?; + Arc::make_mut(&mut turn.config) + .features + .enable(Feature::ToolRouter)?; let repo_key = { #[allow(deprecated)] { @@ -605,7 +609,9 @@ async fn tool_router_diagnostics_use_original_output_token_hint() -> anyhow::Res codex_state::StateRuntime::init(state_home.path().to_path_buf(), "test".to_string()) .await?; let (mut session, mut turn) = make_session_and_context().await; - turn.features.enable(Feature::ToolRouter)?; + Arc::make_mut(&mut turn.config) + .features + .enable(Feature::ToolRouter)?; session.services.state_db = Some(Arc::clone(&state_db)); session.services.extensions = extension_tool_test_registry(); @@ -655,7 +661,9 @@ async fn tool_router_records_code_mode_diagnostics_without_changing_result() -> codex_state::StateRuntime::init(state_home.path().to_path_buf(), "test".to_string()) .await?; let (mut session, mut turn) = make_session_and_context().await; - turn.features.enable(Feature::ToolRouter)?; + Arc::make_mut(&mut turn.config) + .features + .enable(Feature::ToolRouter)?; session.services.state_db = Some(Arc::clone(&state_db)); session.services.extensions = extension_tool_test_registry(); diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 9ad9d284c786..7ef7687842ad 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -32,7 +32,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::policy_transforms::effective_permission_profile; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::path::PathBuf; use std::time::Instant; @@ -40,14 +40,14 @@ use std::time::Instant; #[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Serialize)] pub(crate) struct ApplyPatchApprovalKey { environment_id: String, - path: AbsolutePathBuf, + path: PathUri, } #[derive(Debug)] pub struct ApplyPatchRequest { pub turn_environment: TurnEnvironment, pub action: ApplyPatchAction, - pub file_paths: Vec, + pub file_paths: Vec, pub changes: std::collections::HashMap, pub exec_approval_requirement: ExecApprovalRequirement, pub additional_permissions: Option, @@ -77,13 +77,20 @@ impl ApplyPatchRuntime { fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, - ) -> GuardianApprovalRequest { - GuardianApprovalRequest::ApplyPatch { + ) -> std::io::Result { + // TODO(anp): Remove this conversion once the guardian API supports PathUri. + let cwd = req.action.cwd.to_abs_path()?; + let files = req + .file_paths + .iter() + .map(PathUri::to_abs_path) + .collect::>>()?; + Ok(GuardianApprovalRequest::ApplyPatch { id: call_id.to_string(), - cwd: req.action.cwd.clone(), - files: req.file_paths.clone(), + cwd, + files, patch: req.action.patch.clone(), - } + }) } fn file_system_sandbox_context_for_attempt( @@ -99,6 +106,11 @@ impl ApplyPatchRuntime { Some(FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(attempt.sandbox_cwd.clone()), + workspace_roots: attempt + .workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: attempt.windows_sandbox_level, windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop, use_legacy_landlock: attempt.use_legacy_landlock, @@ -143,7 +155,16 @@ impl Approvable for ApplyPatchRuntime { let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { if let Some(review_id) = guardian_review_id { - let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); + let action = match ApplyPatchRuntime::build_guardian_review_request( + req, + ctx.call_id, + ) { + Ok(action) => action, + Err(err) => { + tracing::error!(cwd = %req.action.cwd, %err, "guardian apply_patch cwd is not host-native"); + return ReviewDecision::Abort; + } + }; return review_approval_request(session, turn, review_id, action, retry_reason) .await; } @@ -213,7 +234,7 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { - fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a AbsolutePathBuf> { + fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a PathUri> { Some(&req.action.cwd) } 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 4100b662ee25..f1c6f43aa342 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -19,7 +19,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: crate::session::turn_context::TurnEnvironment::new( environment_id.to_string(), std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()), - std::env::temp_dir().abs(), + PathUri::from_abs_path(&std::env::temp_dir().abs()), /*shell*/ None, ) } @@ -53,13 +53,14 @@ async fn guardian_review_request_includes_patch_context() { let path = std::env::temp_dir() .join("guardian-apply-patch-test.txt") .abs(); - let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); - let expected_cwd = action.cwd.clone(); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&path), "hello".to_string()); + let expected_cwd = action.cwd.to_abs_path().expect("native patch cwd"); let expected_patch = action.patch.clone(); let request = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), action, - file_paths: vec![path.clone()], + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::from([( path.to_path_buf(), FileChange::Add { @@ -74,14 +75,15 @@ async fn guardian_review_request_includes_patch_context() { permissions_preapproved: false, }; - let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1"); + let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1") + .expect("native guardian request cwd"); assert_eq!( guardian_request, GuardianApprovalRequest::ApplyPatch { id: "call-1".to_string(), cwd: expected_cwd, - files: request.file_paths, + files: vec![path], patch: expected_patch, } ); @@ -93,12 +95,13 @@ async fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() { let path = std::env::temp_dir() .join("apply-patch-permission-request-payload.txt") .abs(); - let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&path), "hello".to_string()); let expected_patch = action.patch.clone(); let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), action, - file_paths: vec![path], + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::NeedsApproval { reason: None, @@ -129,10 +132,11 @@ async fn approval_keys_include_environment_id() { let path = std::env::temp_dir() .join("apply-patch-approval-key.txt") .abs(); + let path_uri = PathUri::from_abs_path(&path); let req = ApplyPatchRequest { turn_environment: test_turn_environment("remote"), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test(&path_uri, "hello".to_string()), + file_paths: vec![path_uri.clone()], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -149,7 +153,7 @@ async fn approval_keys_include_environment_id() { serde_json::json!([ { "environment_id": "remote", - "path": path, + "path": path_uri, } ]) ); @@ -163,8 +167,11 @@ async fn sandbox_cwd_uses_patch_action_cwd() { .abs(); let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&path), + "hello".to_string(), + ), + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -191,8 +198,11 @@ async fn file_system_sandbox_context_uses_active_attempt() { }; let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&path), + "hello".to_string(), + ), + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -210,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, @@ -258,8 +270,11 @@ async fn no_sandbox_attempt_has_no_file_system_context() { .abs(); let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&path), + "hello".to_string(), + ), + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -273,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 2b879e75cad6..9b485dbe58d3 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, @@ -126,13 +128,14 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: Some(&proxy), SandboxPermissions::RequireEscalated, ), + /*environment_id*/ None, ) .expect("prepare exec request"); - assert_eq!(exec_request.cwd, command_cwd); + assert_eq!(exec_request.cwd, PathUri::from_abs_path(&command_cwd)); assert_eq!( exec_request.windows_sandbox_policy_cwd, - native_sandbox_policy_cwd + PathUri::from_abs_path(&native_sandbox_policy_cwd) ); assert_eq!(exec_request.network, None); for key in PROXY_ENV_KEYS { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6cfa3b2dfcad..0d386103db77 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -93,6 +93,7 @@ pub struct ShellRuntime { #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub(crate) struct ApprovalKey { + environment_id: String, command: Vec, cwd: AbsolutePathBuf, sandbox_permissions: SandboxPermissions, @@ -127,6 +128,7 @@ impl Approvable for ShellRuntime { fn approval_keys(&self, req: &ShellRequest) -> Vec { vec![ApprovalKey { + environment_id: req.turn_environment.environment_id.clone(), command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), sandbox_permissions: req.sandbox_permissions, @@ -142,6 +144,7 @@ impl Approvable for ShellRuntime { let keys = self.approval_keys(req); let command = req.command.clone(); let cwd = req.cwd.clone(); + let environment_id = Some(req.turn_environment.environment_id.clone()); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let session = ctx.session; @@ -173,6 +176,7 @@ impl Approvable for ShellRuntime { turn, call_id, /*approval_id*/ None, + environment_id, command, cwd, reason, @@ -232,6 +236,7 @@ impl ToolRuntime for ShellRuntime { tty: None, }, command: req.hook_command.clone(), + environment_id: req.turn_environment.environment_id.clone(), }) } @@ -317,7 +322,12 @@ impl ToolRuntime for ShellRuntime { capture_policy: ExecCapturePolicy::ShellTool, }; let env = attempt - .env_for(command, options, managed_network) + .env_for( + command, + options, + managed_network, + Some(&req.turn_environment.environment_id), + ) .map_err(ToolError::Codex)?; let out = execute_env(env, Self::stdout_stream(ctx)) .await @@ -325,3 +335,7 @@ impl ToolRuntime for ShellRuntime { Ok(out) } } + +#[cfg(test)] +#[path = "shell_tests.rs"] +mod tests; 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 ea53e9a55c89..a77645f941fe 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -68,6 +68,7 @@ use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::collections::HashMap; +use std::io; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -143,6 +144,7 @@ pub(super) async fn try_run_zsh_fork( command, options, managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions), + Some(&req.turn_environment.environment_id), ) .map_err(ToolError::Codex)?; let crate::sandboxing::ExecRequest { @@ -151,6 +153,7 @@ pub(super) async fn try_run_zsh_fork( env: sandbox_env, exec_server_env_config: _, network: sandbox_network, + network_environment_id, expiration: _sandbox_expiration, capture_policy: _capture_policy, sandbox, @@ -163,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( @@ -172,6 +177,14 @@ pub(super) async fn try_run_zsh_fork( let exec_policy = Arc::new(RwLock::new( ctx.session.services.exec_policy.current().as_ref().clone(), )); + // TODO(anp): Keep PathUri through the shell escalation boundary. + let sandbox_cwd = sandbox_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + // TODO(anp): Keep PathUri through the shell sandbox policy boundary. + let sandbox_policy_cwd = sandbox_policy_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let command_executor = CoreShellCommandExecutor { command, cwd: sandbox_cwd, @@ -181,12 +194,13 @@ pub(super) async fn try_run_zsh_fork( sandbox, env: sandbox_env, network: sandbox_network, + network_environment_id, windows_sandbox_level, arg0, sandbox_policy_cwd, windows_sandbox_workspace_roots, - codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), - use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), + codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), + use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), }; let main_execve_wrapper_exe = ctx .session @@ -222,6 +236,7 @@ pub(super) async fn try_run_zsh_fork( session: Arc::clone(&ctx.session), turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), + environment_id: req.turn_environment.environment_id.clone(), tool_name: GuardianCommandSource::Shell, approval_policy: ctx.turn.approval_policy.value(), permission_profile: command_executor.permission_profile.clone(), @@ -273,27 +288,39 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( let exec_policy = Arc::new(RwLock::new( ctx.session.services.exec_policy.current().as_ref().clone(), )); + // TODO(anp): Keep PathUri through the zsh-fork executor boundary. + let cwd = exec_request + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + // TODO(anp): Keep PathUri through the zsh-fork sandbox policy boundary. + let sandbox_policy_cwd = exec_request + .windows_sandbox_policy_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let command_executor = CoreShellCommandExecutor { command: exec_request.command.clone(), - cwd: exec_request.cwd.clone(), + cwd, permission_profile: exec_request.permission_profile.clone(), file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), network_sandbox_policy: exec_request.network_sandbox_policy, sandbox: exec_request.sandbox, env: exec_request.env.clone(), network: exec_request.network.clone(), + network_environment_id: exec_request.network_environment_id.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), - sandbox_policy_cwd: exec_request.windows_sandbox_policy_cwd.clone(), + sandbox_policy_cwd, windows_sandbox_workspace_roots: exec_request.windows_sandbox_workspace_roots.clone(), - codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), - use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), + codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), + use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), }; let escalation_policy = CoreShellActionProvider { policy: Arc::clone(&exec_policy), session: Arc::clone(&ctx.session), turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), + environment_id: req.turn_environment.environment_id.clone(), tool_name: GuardianCommandSource::UnifiedExec, approval_policy: ctx.turn.approval_policy.value(), permission_profile: exec_request.permission_profile.clone(), @@ -328,6 +355,7 @@ struct CoreShellActionProvider { session: Arc, turn: Arc, call_id: String, + environment_id: String, tool_name: GuardianCommandSource, approval_policy: AskForApproval, permission_profile: PermissionProfile, @@ -424,6 +452,7 @@ impl CoreShellActionProvider { let turn = self.turn.clone(); let call_id = self.call_id.clone(); let approval_id = Some(Uuid::new_v4().to_string()); + let environment_id = Some(self.environment_id.clone()); let source = self.tool_name; let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id); Ok(stopwatch @@ -489,6 +518,7 @@ impl CoreShellActionProvider { &turn, call_id, approval_id, + environment_id, command, workdir.clone(), /*reason*/ None, @@ -786,6 +816,7 @@ struct CoreShellCommandExecutor { sandbox: SandboxType, env: HashMap, network: Option, + network_environment_id: Option, windows_sandbox_level: WindowsSandboxLevel, arg0: Option, sandbox_policy_cwd: AbsolutePathBuf, @@ -852,14 +883,15 @@ impl CoreShellCommandExecutor { let result = crate::sandboxing::execute_exec_request_with_after_spawn( crate::sandboxing::ExecRequest { command: self.command.clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.clone().into(), env: exec_env, exec_server_env_config: None, network: self.network.clone(), + network_environment_id: self.network_environment_id.clone(), expiration: ExecExpiration::Cancellation(cancel_rx), capture_policy: ExecCapturePolicy::ShellTool, sandbox: self.sandbox, - windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone(), + windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone().into(), windows_sandbox_workspace_roots: self.windows_sandbox_workspace_roots.clone(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: false, @@ -868,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, @@ -987,6 +1021,7 @@ impl CoreShellCommandExecutor { permissions: permission_profile, sandbox, enforce_managed_network: self.network.is_some(), + environment_id: self.network_environment_id.as_deref(), network: self.network.as_ref(), sandbox_policy_cwd: &sandbox_policy_cwd, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(), @@ -1000,12 +1035,24 @@ impl CoreShellCommandExecutor { self.windows_sandbox_workspace_roots.clone(), ); if let Some(network) = exec_request.network.as_ref() { - network.apply_to_env(&mut exec_request.env); + network + .apply_to_env_for_optional_environment( + &mut exec_request.env, + self.network_environment_id.as_deref(), + ) + .map_err(|err| { + let environment_id = + self.network_environment_id.as_deref().unwrap_or("default"); + CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{environment_id}`: {err}" + ))) + })?; } Ok(PreparedExec { command: exec_request.command, - cwd: exec_request.cwd.to_path_buf(), + // TODO(anp): Keep PathUri through the execve-wrapper boundary. + cwd: exec_request.cwd.to_abs_path()?.to_path_buf(), env: exec_request.env, arg0: exec_request.arg0, }) diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6d42e7c84dc1..5478c1d946c6 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -366,6 +366,7 @@ async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Re sandbox: SandboxType::None, env: HashMap::new(), network: None, + network_environment_id: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, arg0: None, sandbox_policy_cwd: workdir.clone(), @@ -425,6 +426,7 @@ async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyho session: Arc::new(session), turn: Arc::new(turn_context), call_id: "preapproved-additional-permissions".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, permission_profile: permission_profile.clone(), @@ -560,6 +562,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul session: std::sync::Arc::new(session), turn: std::sync::Arc::new(turn_context), call_id: "execve-hook-call".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), @@ -770,6 +773,7 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") session: Arc::new(session), turn: Arc::new(turn_context), call_id: "deny-read-prefix-allow".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, permission_profile, @@ -806,6 +810,7 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow session: Arc::new(session), turn: Arc::new(turn_context), call_id: "deny-read-granular-sandbox-reject".to_string(), + environment_id: "local".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: false, diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs new file mode 100644 index 000000000000..eaa7adf48cba --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -0,0 +1,42 @@ +use super::*; +use codex_exec_server::Environment; +use codex_utils_path_uri::PathUri; +use std::sync::Arc; + +#[tokio::test] +async fn approval_key_includes_environment_id() { + let cwd = AbsolutePathBuf::try_from(std::env::current_dir().expect("read current dir")) + .expect("current dir is absolute"); + let mut request = ShellRequest { + command: vec!["echo".to_string(), "hello".to_string()], + turn_environment: TurnEnvironment::new( + "remote".to_string(), + Arc::new(Environment::default_for_tests()), + PathUri::from_abs_path(&cwd), + /*shell*/ None, + ), + shell_type: None, + hook_command: "echo hello".to_string(), + cwd: cwd.clone(), + timeout_ms: None, + cancellation_token: CancellationToken::new(), + env: HashMap::new(), + explicit_env_overrides: HashMap::new(), + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, + #[cfg(unix)] + additional_permissions_preapproved: false, + justification: None, + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + }; + let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic); + let original_key = runtime.approval_keys(&request); + request.turn_environment.environment_id = "other".to_string(); + let other_key = runtime.approval_keys(&request); + + assert_ne!(original_key, other_key); +} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 25a1b932ce43..c1d519120471 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -21,7 +21,6 @@ use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::RuntimePathPrepends; #[cfg(unix)] use crate::tools::runtimes::apply_zsh_fork_path_prepend; -use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; @@ -47,13 +46,16 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxablePreference; use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_tools::UnifiedExecShellMode; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::collections::HashMap; +use std::io; use tokio_util::sync::CancellationToken; +use tracing::error; /// Request payload used by the unified-exec runtime after approvals and /// sandbox preferences have been resolved for the current turn. @@ -63,8 +65,8 @@ pub struct UnifiedExecRequest { pub shell_type: ShellType, pub hook_command: String, pub process_id: i32, - pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_cwd: PathUri, pub turn_environment: TurnEnvironment, pub env: HashMap, pub exec_server_env_config: Option, @@ -83,8 +85,9 @@ pub struct UnifiedExecRequest { /// unified-exec launches. #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { + pub environment_id: String, pub command: Vec, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, @@ -110,6 +113,24 @@ fn unified_exec_options( } } +fn build_unified_exec_sandbox_command( + command: &[String], + cwd: &PathUri, + env: &HashMap, + additional_permissions: Option, +) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; + Ok(SandboxCommand { + program: program.clone().into(), + args: args.to_vec(), + cwd: cwd.clone(), + env: env.clone(), + additional_permissions, + }) +} + impl<'a> UnifiedExecRuntime<'a> { /// Creates a runtime bound to the shared unified-exec process manager. pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self { @@ -135,6 +156,7 @@ impl Approvable for UnifiedExecRuntime<'_> { fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { vec![UnifiedExecApprovalKey { + environment_id: req.turn_environment.environment_id.clone(), command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), tty: req.tty, @@ -153,11 +175,20 @@ impl Approvable for UnifiedExecRuntime<'_> { let turn = ctx.turn; let call_id = ctx.call_id.to_string(); let command = req.command.clone(); - let cwd = req.cwd.clone(); + let environment_id = Some(req.turn_environment.environment_id.clone()); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { + let native_cwd = match req.cwd.to_abs_path() { + Ok(c) => c, + Err(e) => { + // TODO(anp) make sandboxing work for foreign OSes, in the meantime this should + // be impossible for single-OS app-servers + error!(cwd = %req.cwd, ?e, "got non-native path in start_approval_async"); + return ReviewDecision::Abort; + } + }; if let Some(review_id) = guardian_review_id { return review_approval_request( session, @@ -166,7 +197,7 @@ impl Approvable for UnifiedExecRuntime<'_> { GuardianApprovalRequest::ExecCommand { id: call_id, command, - cwd: cwd.clone(), + cwd: native_cwd.clone(), sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), justification: req.justification.clone(), @@ -183,8 +214,9 @@ impl Approvable for UnifiedExecRuntime<'_> { turn, call_id, /*approval_id*/ None, + environment_id, command, - cwd.clone(), + native_cwd, reason, ctx.network_approval_context.clone(), req.exec_approval_requirement @@ -222,7 +254,7 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { - fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b AbsolutePathBuf> { + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { Some(&req.sandbox_cwd) } @@ -245,13 +277,14 @@ impl<'a> ToolRuntime for UnifiedExecRunt call_id: ctx.call_id.clone(), tool_name: flat_tool_name(&ctx.tool_name).into_owned(), command: req.command.clone(), - cwd: req.cwd.clone(), + cwd: req.cwd.to_abs_path().ok()?, sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), justification: req.justification.clone(), tty: Some(req.tty), }, command: req.hook_command.clone(), + environment_id: req.turn_environment.environment_id.clone(), }) } @@ -268,7 +301,17 @@ impl<'a> ToolRuntime for UnifiedExecRunt .shell .as_ref() .unwrap_or(session_shell.as_ref()); - let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd); + let environment_is_remote = req.turn_environment.environment.is_remote(); + let shell_snapshot_location = if environment_is_remote { + None + } else { + // TODO(anp): Make shell snapshot lookup accept PathUri. + let native_cwd = req + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + req.turn_environment.shell_snapshot(&native_cwd) + }; let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads( req.sandbox_permissions, @@ -280,9 +323,18 @@ impl<'a> ToolRuntime for UnifiedExecRunt ); let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); if let Some(network) = managed_network { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment( + &mut env, + Some(&req.turn_environment.environment_id), + ) + .map_err(|err| { + ToolError::Codex(CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{}`: {err}", + req.turn_environment.environment_id + )))) + })?; } - let environment_is_remote = req.turn_environment.environment.is_remote(); let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] let runtime_path_prepends = { @@ -329,17 +381,26 @@ impl<'a> ToolRuntime for UnifiedExecRunt }; if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) - .map_err(|error| match error { - ToolError::Rejected(_) => { - ToolError::Rejected("missing command line for PTY".to_string()) - } - error @ ToolError::Codex(_) => error, - })?; + let command = build_unified_exec_sandbox_command( + &command, + &req.cwd, + &env, + req.additional_permissions.clone(), + ) + .map_err(|error| match error { + ToolError::Rejected(_) => { + ToolError::Rejected("missing command line for PTY".to_string()) + } + error @ ToolError::Codex(_) => error, + })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt - .env_for(command, options, managed_network) + .env_for( + command, + options, + managed_network, + Some(&req.turn_environment.environment_id), + ) .map_err(ToolError::Codex)?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); match zsh_fork_backend::maybe_prepare_unified_exec( @@ -360,7 +421,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt } return self .manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( req.process_id, &prepared.exec_request, req.tty, @@ -385,37 +446,33 @@ impl<'a> ToolRuntime for UnifiedExecRunt } } } - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) - .map_err(|error| match error { - ToolError::Rejected(_) => { - ToolError::Rejected("missing command line for PTY".to_string()) - } - error @ ToolError::Codex(_) => error, - })?; + let command = build_unified_exec_sandbox_command( + &command, + &req.cwd, + &env, + req.additional_permissions.clone(), + ) + .map_err(|error| match error { + ToolError::Rejected(_) => { + ToolError::Rejected("missing command line for PTY".to_string()) + } + error @ ToolError::Codex(_) => error, + })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); - let mut exec_env = attempt - .env_for(command, options, managed_network) - .map_err(ToolError::Codex)?; - exec_env.exec_server_env_config = req.exec_server_env_config.clone(); self.manager .open_session_with_exec_env( req.process_id, - &exec_env, + command, + options, + attempt, + managed_network, + /*environment_id*/ Some(&req.turn_environment.environment_id), + req.exec_server_env_config.clone(), req.tty, Box::new(NoopSpawnLifecycle), req.turn_environment.environment.as_ref(), ) .await - .map_err(|err| match err { - UnifiedExecError::SandboxDenied { output, .. } => { - ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - })) - } - other => ToolError::Rejected(other.to_string()), - }) } } @@ -427,11 +484,13 @@ mod tests { use codex_exec_server::Environment; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_tools::ZshForkConfig; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use std::sync::Arc; use std::time::Duration; use tempfile::tempdir; - fn test_turn_environment(cwd: AbsolutePathBuf) -> TurnEnvironment { + fn test_turn_environment(cwd: PathUri) -> TurnEnvironment { TurnEnvironment::new( LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), @@ -462,6 +521,25 @@ mod tests { } } + #[tokio::test] + async fn approval_key_includes_environment_id() { + let manager = UnifiedExecProcessManager::default(); + let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let mut request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + ); + request.turn_environment.environment_id = "remote".to_string(); + let original_key = runtime.approval_keys(&request); + request.turn_environment.environment_id = "other".to_string(); + let other_key = runtime.approval_keys(&request); + + assert_ne!(original_key, other_key); + } + #[tokio::test] async fn unified_exec_uses_the_trusted_sandbox_cwd() { let cwd_dir = tempdir().expect("create process temp dir"); @@ -477,9 +555,9 @@ mod tests { shell_type: ShellType::Sh, hook_command: "pwd".to_string(), process_id: 1000, - cwd, - sandbox_cwd: sandbox_cwd.clone(), - turn_environment: test_turn_environment(sandbox_cwd.clone()), + cwd: cwd.into(), + sandbox_cwd: sandbox_cwd.clone().into(), + turn_environment: test_turn_environment(sandbox_cwd.clone().into()), env: HashMap::new(), exec_server_env_config: None, explicit_env_overrides: HashMap::new(), @@ -496,7 +574,10 @@ mod tests { }, }; - assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd)); + assert_eq!( + runtime.sandbox_cwd(&request), + Some(&PathUri::from_abs_path(&sandbox_cwd)) + ); } #[tokio::test] @@ -576,9 +657,9 @@ mod tests { shell_type: ShellType::Zsh, hook_command: "echo hi".to_string(), process_id: 1000, - cwd: cwd.clone(), - sandbox_cwd: cwd.clone(), - turn_environment: test_turn_environment(cwd), + cwd: cwd.clone().into(), + sandbox_cwd: cwd.clone().into(), + turn_environment: test_turn_environment(cwd.into()), env: HashMap::new(), exec_server_env_config: None, explicit_env_overrides: HashMap::new(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 499cdfbdaeda..77f68d96b511 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; @@ -394,7 +396,7 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { None } - fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a AbsolutePathBuf> { + fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a PathUri> { None } @@ -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, @@ -426,6 +432,7 @@ impl<'a> SandboxAttempt<'a> { command: SandboxCommand, options: ExecOptions, network: Option<&NetworkProxy>, + environment_id: Option<&str>, ) -> Result { let request = self .manager @@ -434,6 +441,7 @@ impl<'a> SandboxAttempt<'a> { permissions: self.permissions, sandbox: self.sandbox, enforce_managed_network: self.enforce_managed_network, + environment_id, network, sandbox_policy_cwd: self.sandbox_cwd, codex_linux_sandbox_exe: self @@ -450,6 +458,53 @@ impl<'a> SandboxAttempt<'a> { self.workspace_roots.to_vec(), )) } + + pub fn env_for_exec_server( + &self, + command: SandboxCommand, + options: ExecOptions, + 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 { + command, + permissions: self.permissions, + // The exec-server must receive the native command, not this host's wrapper. + sandbox: SandboxType::None, + enforce_managed_network: self.enforce_managed_network, + environment_id, + network, + sandbox_policy_cwd: self.sandbox_cwd, + codex_linux_sandbox_exe: None, + use_legacy_landlock: self.use_legacy_landlock, + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + }) + .map_err(CodexErr::from)?; + 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) + } } #[cfg(test)] diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 21719e281f9d..c647e1c40113 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -5,8 +5,14 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::protocol::GranularApprovalConfig; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxType; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::HashMap; #[test] fn bash_permission_request_payload_omits_missing_description() { @@ -193,3 +199,70 @@ fn deny_read_blocks_explicit_escalation_and_policy_bypass() { "exec-policy allow rules would drop deny-read filesystem policy, so keep the first attempt sandboxed", ); } + +#[test] +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 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::None, + sandbox_requested: true, + permissions: &permissions, + exec_server_permissions: &exec_server_permissions, + enforce_managed_network: true, + manager: &manager, + sandbox_cwd: &cwd_uri, + workspace_roots: std::slice::from_ref(&cwd), + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + network_denial_cancellation_token: None, + }; + let command = SandboxCommand { + program: "/bin/bash".into(), + args: vec!["-lc".to_string(), "pwd".to_string()], + cwd: cwd_uri.clone(), + env: HashMap::new(), + additional_permissions: None, + }; + let options = crate::sandboxing::ExecOptions { + expiration: crate::exec::ExecExpiration::DefaultTimeout, + capture_policy: crate::exec::ExecCapturePolicy::ShellTool, + }; + + let request = attempt + .env_for_exec_server(command, options, /*network*/ None, Some("remote")) + .expect("prepare remote exec request"); + + assert_eq!( + request.command, + vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "pwd".to_string() + ] + ); + 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 074394fae6fd..f1dd6b80159e 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -3,9 +3,11 @@ use crate::agent::next_thread_spawn_depth; use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; +use crate::tools::effective_tool_mode; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; +use crate::tools::handlers::CurrentTimeHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ExecCommandHandlerOptions; @@ -69,7 +71,6 @@ use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; @@ -146,7 +147,7 @@ struct CoreToolPlanContext<'a> { turn_context: &'a TurnContext, mcp_tools: Option<&'a [ToolInfo]>, deferred_mcp_tools: Option<&'a [ToolInfo]>, - discoverable_tools: Option<&'a [DiscoverableTool]>, + tool_suggest_candidates: Option<&'a crate::tools::router::ToolSuggestCandidates>, extension_tool_executors: &'a [Arc>], dynamic_tools: &'a [DynamicToolSpec], tool_search_handler_cache: &'a ToolSearchHandlerCache, @@ -174,7 +175,7 @@ fn build_tool_specs_and_registry( let ToolRouterParams { mcp_tools, deferred_mcp_tools, - discoverable_tools, + tool_suggest_candidates, extension_tool_executors, dynamic_tools, } = params; @@ -184,7 +185,7 @@ fn build_tool_specs_and_registry( turn_context, mcp_tools: mcp_tools.as_deref(), deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), + tool_suggest_candidates: tool_suggest_candidates.as_ref(), extension_tool_executors: &extension_tool_executors, dynamic_tools, tool_search_handler_cache, @@ -193,11 +194,41 @@ fn build_tool_specs_and_registry( }; let mut planned_tools = PlannedTools::default(); add_tool_sources(&context, &mut planned_tools); + apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools); append_tool_search_executor(&context, &mut planned_tools); prepend_code_mode_executors(&context, &mut planned_tools); build_model_visible_specs_and_registry(turn_context, planned_tools) } +fn apply_direct_model_only_namespace_overrides( + turn_context: &TurnContext, + planned_tools: &mut PlannedTools, +) { + for runtime in &mut planned_tools.runtimes { + let configured = runtime + .tool_name() + .namespace + .as_ref() + .is_some_and(|namespace| { + turn_context + .config + .code_mode + .direct_only_tool_namespaces + .contains(namespace) + }); + match runtime.exposure() { + ToolExposure::Direct | ToolExposure::Deferred if configured => { + *runtime = + override_tool_exposure(Arc::clone(runtime), ToolExposure::DirectModelOnly); + } + ToolExposure::Direct + | ToolExposure::Deferred + | ToolExposure::DirectModelOnly + | ToolExposure::Hidden => {} + } + } +} + #[instrument(level = "trace", skip_all)] fn build_model_visible_specs_and_registry( turn_context: &TurnContext, @@ -245,10 +276,9 @@ fn spec_for_model_request( tool_name: &ToolName, spec: ToolSpec, ) -> ToolSpec { - if matches!( - turn_context.tool_mode, - ToolMode::CodeMode | ToolMode::CodeModeOnly - ) && exposure != ToolExposure::DirectModelOnly + let tool_mode = effective_tool_mode(turn_context); + if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) + && exposure != ToolExposure::DirectModelOnly && !is_excluded_from_code_mode(turn_context, tool_name) && codex_code_mode::is_code_mode_nested_tool(spec.name()) { @@ -258,6 +288,7 @@ fn spec_for_model_request( } } +#[instrument(level = "trace", skip_all)] fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { let turn_context = context.turn_context; // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools. @@ -297,11 +328,15 @@ 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.features.get().enabled(Feature::ToolRouter) + && turn_context + .config + .features + .get() + .enabled(Feature::ToolRouter) } pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool { - let features = turn_context.features.get(); + let features = turn_context.config.features.get(); features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) && features.enabled(Feature::Plugins) @@ -327,7 +362,12 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool { } fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { - turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context) + turn_context + .config + .features + .get() + .enabled(Feature::SpawnCsv) + && collab_tools_enabled(turn_context) } fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { @@ -342,6 +382,7 @@ fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { image_generation_runtime_enabled(turn_context) && turn_context + .config .features .get() .enabled(Feature::ImageGeneration) @@ -368,7 +409,11 @@ fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool return true; } - turn_context.features.get().enabled(Feature::ImageGenExt) + turn_context + .config + .features + .get() + .enabled(Feature::ImageGenExt) } fn standalone_image_generation_available( @@ -415,7 +460,8 @@ fn is_hidden_by_code_mode_only( tool_name: &ToolName, exposure: ToolExposure, ) -> bool { - turn_context.tool_mode == ToolMode::CodeModeOnly + let tool_mode = effective_tool_mode(turn_context); + tool_mode == ToolMode::CodeModeOnly && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( tool_name, @@ -436,10 +482,8 @@ fn build_code_mode_executors( turn_context: &TurnContext, executors: &[Arc], ) -> Vec> { - if !matches!( - turn_context.tool_mode, - ToolMode::CodeMode | ToolMode::CodeModeOnly - ) { + let tool_mode = effective_tool_mode(turn_context); + if !matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) { return vec![]; } @@ -485,7 +529,7 @@ fn build_code_mode_executors( create_code_mode_tool( &enabled_tools, &namespace_descriptions, - turn_context.tool_mode == ToolMode::CodeModeOnly, + tool_mode == ToolMode::CodeModeOnly, deferred_tools_available, ), code_mode_nested_tool_specs, @@ -494,6 +538,7 @@ fn build_code_mode_executors( ] } +#[instrument(level = "trace", skip_all, fields(tool_spec_count = specs.len()))] fn merge_into_namespaces(specs: Vec) -> Vec { let mut merged_specs = Vec::with_capacity(specs.len()); let mut namespace_indices = BTreeMap::::new(); @@ -580,14 +625,16 @@ fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { namespace_tools_enabled(turn_context) && (turn_context.model_info.use_responses_lite || turn_context + .config .features .get() .enabled(Feature::StandaloneWebSearch)) } +#[instrument(level = "trace", skip_all)] fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; - let features = turn_context.features.get(); + let features = turn_context.config.features.get(); let environment_mode = turn_context.tool_environment_mode(); if !environment_mode.has_environment() { return; @@ -639,6 +686,7 @@ fn unified_exec_should_include_shell_parameter(turn_context: &TurnContext) -> bo .any(|environment| environment.environment.is_remote()) } +#[instrument(level = "trace", skip_all)] fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { if context.mcp_tools.is_some() { planned_tools.add(ListMcpResourcesHandler); @@ -647,9 +695,10 @@ fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument(level = "trace", skip_all)] fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; - let features = turn_context.features.get(); + let features = turn_context.config.features.get(); let environment_mode = turn_context.tool_environment_mode(); planned_tools.add(PlanHandler); @@ -668,23 +717,33 @@ 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); } + if features.enabled(Feature::CurrentTimeReminder) { + planned_tools.add(CurrentTimeHandler); + } + if features.enabled(Feature::SleepTool) { planned_tools.add(SleepHandler); } if tool_suggest_enabled(turn_context) - && let Some(discoverable_tools) = - context.discoverable_tools.filter(|tools| !tools.is_empty()) + && let Some(candidates) = context + .tool_suggest_candidates + .filter(|candidates| !candidates.tools.is_empty()) { - planned_tools.add(ListAvailablePluginsToInstallHandler::new( - collect_request_plugin_install_entries(discoverable_tools), - )); + if candidates.presentation == crate::tools::router::ToolSuggestPresentation::ListTool { + planned_tools.add(ListAvailablePluginsToInstallHandler::new( + collect_request_plugin_install_entries(&candidates.tools), + )); + } planned_tools.add(RequestPluginInstallHandler::new( - discoverable_tools.to_vec(), + candidates.tools.clone(), + candidates.presentation, )); } @@ -714,6 +773,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument(level = "trace", skip_all)] fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; if collab_tools_enabled(turn_context) { @@ -737,7 +797,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, @@ -781,7 +840,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu 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, @@ -802,6 +860,14 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu } } +#[instrument( + level = "trace", + skip_all, + fields( + direct_mcp_tool_count = context.mcp_tools.map_or(0, <[ToolInfo]>::len), + deferred_mcp_tool_count = context.deferred_mcp_tools.map_or(0, <[ToolInfo]>::len) + ) +)] fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { if let Some(mcp_tools) = context.mcp_tools { for tool in mcp_tools { @@ -828,6 +894,11 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument( + level = "trace", + skip_all, + fields(dynamic_tool_count = context.dynamic_tools.len()) +)] fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { for spec in context.dynamic_tools { match spec { @@ -860,6 +931,11 @@ fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plan } } +#[instrument( + level = "trace", + skip_all, + fields(extension_tool_executor_count = context.extension_tool_executors.len()) +)] fn add_extension_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { // Extension ToolContributor implementations are resolved into executors // before planning. Core only adapts those executors into its runtime set. @@ -917,10 +993,8 @@ fn append_extension_tool_executors( .iter() .map(|executor| executor.tool_name()) .collect::>(); - if matches!( - turn_context.tool_mode, - ToolMode::CodeMode | ToolMode::CodeModeOnly - ) { + let tool_mode = effective_tool_mode(turn_context); + if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) { reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME)); reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bdee86353710..46c0f0ffeef1 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -36,12 +36,14 @@ use crate::tools::handlers::ToolSearchHandlerCache; use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE; use crate::tools::router::ToolRouter; use crate::tools::router::ToolRouterParams; +use crate::tools::router::ToolSuggestCandidates; +use crate::tools::router::ToolSuggestPresentation; #[derive(Default)] struct ToolPlanInputs { mcp_tools: Option>, deferred_mcp_tools: Option>, - discoverable_tools: Option>, + tool_suggest_candidates: Option, extension_tool_executors: Vec>>, dynamic_tools: Vec, } @@ -179,9 +181,9 @@ async fn probe_with( let router = ToolRouter::from_turn_context( &turn, ToolRouterParams { + tool_suggest_candidates: inputs.tool_suggest_candidates, mcp_tools: inputs.mcp_tools, deferred_mcp_tools: inputs.deferred_mcp_tools, - discoverable_tools: inputs.discoverable_tools, extension_tool_executors: inputs.extension_tool_executors, dynamic_tools: inputs.dynamic_tools.as_slice(), }, @@ -195,16 +197,6 @@ async fn probe(configure_turn: impl FnOnce(&mut TurnContext)) -> ToolPlanProbe { } fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { - if enabled { - turn.features - .enable(feature) - .expect("test feature should be enableable"); - } else { - turn.features - .disable(feature) - .expect("test feature should be disableable"); - } - let mut config = (*turn.config).clone(); if enabled { config @@ -219,15 +211,6 @@ fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { } turn.multi_agent_version = config.multi_agent_version_from_features(); turn.config = Arc::new(config); - turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| { - if turn.config.features.enabled(Feature::CodeModeOnly) { - ToolMode::CodeModeOnly - } else if turn.config.features.enabled(Feature::CodeMode) { - ToolMode::CodeMode - } else { - ToolMode::Direct - } - }); } fn set_features(turn: &mut TurnContext, features: &[Feature]) { @@ -410,17 +393,19 @@ fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> Dyn } } -fn discoverable_plugin(id: &str, name: &str) -> DiscoverableTool { - DiscoverablePluginInfo { - id: id.to_string(), - remote_plugin_id: None, - name: name.to_string(), - description: Some(format!("{name} plugin")), - has_skills: false, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), +fn plugin_candidates(presentation: ToolSuggestPresentation) -> ToolSuggestCandidates { + ToolSuggestCandidates { + tools: vec![DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "github@openai-curated-remote".to_string(), + remote_plugin_id: None, + name: "GitHub".to_string(), + description: Some("Work with GitHub repositories".to_string()), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }))], + presentation, } - .into() } fn has_parameter(spec: &ToolSpec, parameter_name: &str) -> bool { @@ -799,7 +784,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() { ToolRouterParams { mcp_tools: None, deferred_mcp_tools: Some(vec![mcp_tool("first", "mcp__first", "lookup")]), - discoverable_tools: None, + tool_suggest_candidates: None, extension_tool_executors: Vec::new(), dynamic_tools: &[], }, @@ -815,7 +800,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() { ToolRouterParams { mcp_tools: None, deferred_mcp_tools: Some(vec![mcp_tool("second", "mcp__second", "lookup")]), - discoverable_tools: None, + tool_suggest_candidates: None, extension_tool_executors: Vec::new(), dynamic_tools: &[], }, @@ -860,8 +845,7 @@ async fn invalid_mcp_tools_are_not_registered() { } #[tokio::test] -async fn request_plugin_install_requires_all_discovery_features_and_discoverable_tools() { - let discoverable_tools = Some(vec![discoverable_plugin("github", "GitHub")]); +async fn request_plugin_install_requires_all_discovery_features() { for disabled_feature in [Feature::ToolSuggest, Feature::Apps, Feature::Plugins] { let plan = probe_with( |turn| { @@ -872,7 +856,7 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable set_feature(turn, disabled_feature, /*enabled*/ false); }, ToolPlanInputs { - discoverable_tools: discoverable_tools.clone(), + tool_suggest_candidates: Some(plugin_candidates(ToolSuggestPresentation::ListTool)), ..ToolPlanInputs::default() }, ) @@ -883,17 +867,31 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable ]); } - let no_candidates = probe(|turn| { - set_features( - turn, - &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], - ); - }) - .await; - no_candidates.assert_visible_lacks(&[ - "list_available_plugins_to_install", - "request_plugin_install", - ]); + for tool_suggest_candidates in [ + None, + Some(ToolSuggestCandidates { + tools: Vec::new(), + presentation: ToolSuggestPresentation::RecommendationContext, + }), + ] { + let plan = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + tool_suggest_candidates, + ..ToolPlanInputs::default() + }, + ) + .await; + plan.assert_visible_lacks(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); + } let enabled = probe_with( |turn| { @@ -903,7 +901,7 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable ); }, ToolPlanInputs { - discoverable_tools, + tool_suggest_candidates: Some(plugin_candidates(ToolSuggestPresentation::ListTool)), ..ToolPlanInputs::default() }, ) @@ -915,7 +913,7 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable } #[tokio::test] -async fn install_suggestion_tools_stay_visible_without_tool_search() { +async fn request_plugin_install_stays_visible_without_tool_search() { let plan = probe_with( |turn| { turn.model_info.supports_search_tool = false; @@ -925,7 +923,7 @@ async fn install_suggestion_tools_stay_visible_without_tool_search() { ); }, ToolPlanInputs { - discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + tool_suggest_candidates: Some(plugin_candidates(ToolSuggestPresentation::ListTool)), ..ToolPlanInputs::default() }, ) @@ -939,7 +937,7 @@ async fn install_suggestion_tools_stay_visible_without_tool_search() { } #[tokio::test] -async fn request_plugin_install_description_defers_inventory_to_list_tool() { +async fn request_plugin_install_description_refers_to_recommended_plugins_hint() { let plan = probe_with( |turn| { set_features( @@ -948,34 +946,32 @@ async fn request_plugin_install_description_defers_inventory_to_list_tool() { ); }, ToolPlanInputs { - discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + tool_suggest_candidates: Some(plugin_candidates( + ToolSuggestPresentation::RecommendationContext, + )), ..ToolPlanInputs::default() }, ) .await; - let ToolSpec::Function(ResponsesApiTool { - description: list_description, - .. - }) = plan.visible_spec("list_available_plugins_to_install") - else { - panic!("expected list_available_plugins_to_install function spec"); - }; - assert!(list_description.contains( - "Returns known plugins and connectors that can be passed to `request_plugin_install`." - )); - + let request_spec = plan.visible_spec("request_plugin_install"); let ToolSpec::Function(ResponsesApiTool { description: request_description, .. - }) = plan.visible_spec("request_plugin_install") + }) = request_spec else { panic!("expected request_plugin_install function spec"); }; - assert!(request_description.contains( - "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request." - )); + assert!(request_description.contains("the `` list")); + assert!(!request_description.contains("list_available_plugins_to_install")); assert!(!request_description.contains("github")); + assert!(has_parameter(request_spec, "plugin_id")); + assert!(has_parameter(request_spec, "suggest_reason")); + assert!(!has_parameter(request_spec, "tool_id")); + assert!(!has_parameter(request_spec, "tool_type")); + assert!(!has_parameter(request_spec, "action_type")); + plan.assert_visible_lacks(&["list_available_plugins_to_install"]); + plan.assert_registered_lacks(&["list_available_plugins_to_install"]); } #[tokio::test] @@ -1022,6 +1018,48 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { ); } +#[tokio::test] +async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { + let plan = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + turn.model_info.supports_search_tool = true; + update_config(turn, |config| { + config.code_mode.direct_only_tool_namespaces = vec!["direct_only".to_string()]; + }); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("direct_only"), + "lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + plan.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + "direct_only", + ]); + plan.assert_visible_lacks(&["tool_search"]); + assert_eq!( + plan.exposure(&ToolName::namespaced("direct_only", "lookup").to_string()), + ToolExposure::DirectModelOnly + ); + let ToolSpec::Namespace(namespace) = plan.visible_spec("direct_only") else { + panic!("expected direct-only namespace spec"); + }; + let ResponsesApiNamespaceTool::Function(tool) = &namespace.tools[0]; + assert_eq!(tool.defer_loading, None); + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!(!exec.description.contains("direct_only_lookup(args:")); +} + #[tokio::test] async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { let plan = probe_with( @@ -1189,7 +1227,6 @@ async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); turn.model_info.tool_mode = Some(ToolMode::Direct); - turn.tool_mode = ToolMode::Direct; }) .await; direct.assert_visible_lacks(&[ @@ -1413,6 +1450,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { live_web_search.visible_spec("web_search"), &ToolSpec::WebSearch { external_web_access: Some(true), + index_gated_web_access: None, filters: None, user_location: None, search_context_size: None, diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs index a7be3d2b0c32..9c58de913bc5 100644 --- a/codex-rs/core/src/turn_diff_tracker_tests.rs +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -4,7 +4,7 @@ use codex_apply_patch::MaybeApplyPatchVerified; use codex_exec_server::LOCAL_FS; use codex_git_utils::ApplyGitRequest; use codex_git_utils::apply_git_patch; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -18,7 +18,7 @@ fn git_blob_sha1_hex(data: &str) -> String { } async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta { - let cwd = AbsolutePathBuf::from_absolute_path(root).expect("absolute tempdir path"); + let cwd = PathUri::from_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 d13c081817ba..0a1f781b38ed 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 5e6d1f32bb6a..83649b918729 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 8a93a91833f8..e7a90800b0fe 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 { .. } diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index af5a54fec8b7..956468da2371 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,13 +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/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 33dbf843dc7e..38010754e96e 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -22,7 +22,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecOutputStream; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); @@ -110,7 +110,7 @@ pub(crate) fn spawn_exit_watcher( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: i32, transcript: Arc>, started_at: Instant, @@ -197,7 +197,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, @@ -242,7 +242,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index 0576e7d7cfd2..b4e2882c9bbd 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -1,4 +1,5 @@ use codex_protocol::exec_output::ExecToolCallOutput; +use codex_utils_path_uri::PathUri; use thiserror::Error; #[derive(Debug, Error)] @@ -23,6 +24,8 @@ pub(crate) enum UnifiedExecError { message: String, output: ExecToolCallOutput, }, + #[error("{path} is not valid on {}", std::env::consts::OS)] + ForeignPath { path: PathUri }, } impl UnifiedExecError { diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 792d4fde1bd0..97eb846e6e33 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -33,10 +33,10 @@ use codex_features::Feature; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; use codex_tools::UnifiedExecShellMode; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::formatted_truncate_text; +use codex_utils_path_uri::PathUri; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -105,8 +105,8 @@ pub(crate) struct ExecCommandRequest { pub process_id: i32, pub yield_time_ms: u64, pub max_output_tokens: Option, - pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_cwd: PathUri, pub turn_environment: TurnEnvironment, pub shell_mode: UnifiedExecShellMode, pub network: Option, @@ -173,7 +173,7 @@ struct ProcessEntry { process: Arc, call_id: String, process_id: i32, - cwd: AbsolutePathBuf, + cwd: PathUri, initial_exec_command_active: Arc, hook_command: String, tty: bool, @@ -284,7 +284,12 @@ pub(crate) async fn compact_exec_output_for_turn( max_output_tokens, truncation_policy, } = request; - if !turn.features.enabled(Feature::ExecOutputCompaction) { + if !turn + .config + .features + .get() + .enabled(Feature::ExecOutputCompaction) + { return None; } @@ -296,7 +301,11 @@ pub(crate) async fn compact_exec_output_for_turn( model_slug: turn.model_info.slug.as_str(), model_provider: turn.config.model_provider_id.as_str(), tool_name, - tool_router_output_optimization_enabled: turn.features.enabled(Feature::ToolRouter), + tool_router_output_optimization_enabled: turn + .config + .features + .get() + .enabled(Feature::ToolRouter), thread_id: Some(thread_id.as_str()), call_id: Some(call_id), }, @@ -366,6 +375,10 @@ fn compact_candidate_for_response( output, TruncationPolicy::Tokens(max_tokens), )); + if compaction.compacted_token_count >= raw_returned_tokens { + return None; + } + let compacted_returned_tokens = approx_token_count(&formatted_truncate_text( compaction.text.as_str(), TruncationPolicy::Tokens(max_tokens), diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 091be2b3e4a7..67e042e1fb93 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -20,6 +20,7 @@ use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; use codex_sandboxing::SandboxType; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use core_test_support::get_remote_test_env; @@ -77,6 +78,7 @@ fn test_exec_request( cwd, env, network, + /*network_environment_id*/ None, ExecExpiration::DefaultTimeout, ExecCapturePolicy::ShellTool, SandboxType::None, @@ -107,7 +109,7 @@ async fn exec_command_with_tty( let process = Arc::new( manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( process_id, &request, tty, @@ -129,15 +131,23 @@ async fn exec_command_with_tty( process: Arc::clone(&process), call_id: context.call_id.clone(), process_id, - cwd: cwd.clone(), + cwd: cwd.clone().into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: cmd.to_string(), tty, network_approval: None, session: Arc::downgrade(session), last_used: started_at, - exec_output_compaction_enabled: turn.features.enabled(Feature::ExecOutputCompaction), - tool_router_output_optimization_enabled: turn.features.enabled(Feature::ToolRouter), + exec_output_compaction_enabled: turn + .config + .features + .get() + .enabled(Feature::ExecOutputCompaction), + tool_router_output_optimization_enabled: turn + .config + .features + .get() + .enabled(Feature::ToolRouter), model_slug: turn.model_info.slug.clone(), model_provider: turn.config.model_provider_id.clone(), }; @@ -196,7 +206,7 @@ async fn exec_command_with_tty( wall_time, raw_output: collected, compaction: None, - truncation_policy: turn.truncation_policy, + truncation_policy: turn.model_info.truncation_policy.into(), max_output_tokens: None, process_id: response_process_id, exit_code, @@ -232,6 +242,7 @@ impl BlockingTerminateExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) } @@ -290,17 +301,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?, )) } @@ -662,7 +670,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { item_id: "call".to_string(), process_id: process_id.to_string(), command: "bash -i".to_string(), - cwd, + cwd: cwd.into(), }] ); @@ -966,7 +974,7 @@ async fn terminating_initial_exec_command_rechecks_initial_response_state() -> a process, call_id: "call".to_string(), process_id, - cwd, + cwd: cwd.into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: "sleep 60".to_string(), tty: true, @@ -1043,7 +1051,7 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu process: Arc::clone(&process), call_id: "call".to_string(), process_id, - cwd, + cwd: cwd.into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), hook_command: "sleep 60".to_string(), tty: true, @@ -1109,7 +1117,7 @@ async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { let environment = codex_exec_server::Environment::default_for_tests(); let process = UnifiedExecProcessManager::default() - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( /*process_id*/ 1234, &request, /*tty*/ false, @@ -1151,7 +1159,7 @@ async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Resul let manager = UnifiedExecProcessManager::default(); let process = manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( /*process_id*/ 1234, &request, /*tty*/ true, @@ -1208,7 +1216,7 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() let manager = UnifiedExecProcessManager::default(); let err = manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( /*process_id*/ 1234, &request, /*tty*/ true, diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 725be9eed228..5f785adfa75a 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 800a7c668557..bfd5ecaa2232 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -10,11 +10,13 @@ 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; use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; +use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::ExecServerEnvConfig; use crate::tools::context::ExecCommandToolOutput; @@ -26,6 +28,7 @@ use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; +use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::unified_exec::ExecCommandRequest; @@ -57,12 +60,13 @@ use crate::unified_exec::process::OutputHandles; use crate::unified_exec::process::SpawnLifecycleHandle; use crate::unified_exec::process::UnifiedExecProcess; use codex_features::Feature; +use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::protocol::ExecCommandSource; +use codex_sandboxing::SandboxCommand; use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::approx_token_count; use codex_utils_path_uri::PathUri; @@ -161,15 +165,23 @@ 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: PathUri::from_abs_path(&request.cwd), + cwd: request.cwd.clone(), env_policy, env, tty, pipe_stdin: false, arg0: request.arg0.clone(), + sandbox: request.exec_server_sandbox.clone(), + enforce_managed_network: request.exec_server_enforce_managed_network, } } @@ -204,10 +216,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() @@ -305,7 +313,7 @@ async fn emit_failed_initial_exec_end_if_unstored( process_started_alive: bool, context: &UnifiedExecContext, request: &ExecCommandRequest, - cwd: AbsolutePathBuf, + cwd: PathUri, transcript: Arc>, fallback_output: String, message: String, @@ -659,8 +667,12 @@ impl UnifiedExecProcessManager { }; let original_token_count = approx_token_count(&text); - let exec_output_compaction_enabled = - context.turn.features.enabled(Feature::ExecOutputCompaction); + let exec_output_compaction_enabled = context + .turn + .config + .features + .get() + .enabled(Feature::ExecOutputCompaction); let compaction = if response_process_id.is_none() && exec_output_compaction_enabled { compact_exec_output_for_turn(ExecOutputCompactionTurnRequest { session: context.session.as_ref(), @@ -670,7 +682,7 @@ impl UnifiedExecProcessManager { command: &request.command, output: text.as_str(), max_output_tokens: request.max_output_tokens, - truncation_policy: context.turn.truncation_policy, + truncation_policy: context.turn.model_info.truncation_policy.into(), }) .await } else { @@ -686,7 +698,7 @@ impl UnifiedExecProcessManager { wall_time, raw_output: collected, compaction, - truncation_policy: context.turn.truncation_policy, + truncation_policy: context.turn.model_info.truncation_policy.into(), max_output_tokens: request.max_output_tokens, process_id: response_process_id, exit_code, @@ -964,7 +976,7 @@ impl UnifiedExecProcessManager { context: &UnifiedExecContext, command: &[String], hook_command: String, - cwd: AbsolutePathBuf, + cwd: PathUri, started_at: Instant, process_id: i32, tty: bool, @@ -985,11 +997,15 @@ impl UnifiedExecProcessManager { last_used: started_at, exec_output_compaction_enabled: context .turn + .config .features + .get() .enabled(Feature::ExecOutputCompaction), tool_router_output_optimization_enabled: context .turn + .config .features + .get() .enabled(Feature::ToolRouter), model_slug: context.turn.model_info.slug.clone(), model_provider: context.turn.config.model_provider_id.clone(), @@ -1020,7 +1036,47 @@ impl UnifiedExecProcessManager { ); } + #[allow(clippy::too_many_arguments)] pub(crate) async fn open_session_with_exec_env( + &self, + process_id: i32, + command: SandboxCommand, + options: ExecOptions, + attempt: &SandboxAttempt<'_>, + network: Option<&NetworkProxy>, + environment_id: Option<&str>, + exec_server_env_config: Option, + tty: bool, + spawn_lifecycle: SpawnLifecycleHandle, + environment: &codex_exec_server::Environment, + ) -> Result { + let mut request = if environment.is_remote() { + attempt.env_for_exec_server(command, options, network, environment_id) + } else { + attempt.env_for(command, options, network, environment_id) + } + .map_err(ToolError::Codex)?; + request.exec_server_env_config = exec_server_env_config; + self.open_session_with_prepared_exec_env( + process_id, + &request, + tty, + spawn_lifecycle, + environment, + ) + .await + .map_err(|err| match err { + UnifiedExecError::SandboxDenied { output, .. } => { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + })) + } + other => ToolError::Rejected(other.to_string()), + }) + } + + pub(crate) async fn open_session_with_prepared_exec_env( &self, process_id: i32, request: &ExecRequest, @@ -1032,6 +1088,14 @@ impl UnifiedExecProcessManager { #[cfg(target_os = "windows")] if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { + // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. + let native_cwd = + request + .cwd + .to_abs_path() + .map_err(|_| UnifiedExecError::ForeignPath { + path: request.cwd.clone(), + })?; let codex_home = crate::config::find_codex_home().map_err(|err| { UnifiedExecError::create_process(format!( "windows sandbox: failed to resolve codex_home: {err}" @@ -1066,7 +1130,7 @@ impl UnifiedExecProcessManager { request.windows_sandbox_workspace_roots.as_slice(), codex_home.as_ref(), request.command.clone(), - request.cwd.as_path(), + native_cwd.as_path(), request.env.clone(), request.network.is_some(), None, @@ -1088,7 +1152,7 @@ impl UnifiedExecProcessManager { request.windows_sandbox_workspace_roots.as_slice(), codex_home.as_ref(), request.command.clone(), - request.cwd.as_path(), + native_cwd.as_path(), request.env.clone(), None, &additional_deny_read_paths, @@ -1121,9 +1185,17 @@ 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. + let native_cwd = request + .cwd + .to_abs_path() + .map_err(|_| UnifiedExecError::ForeignPath { + path: request.cwd.clone(), + })?; + let (program, args) = request .command .split_first() @@ -1132,7 +1204,7 @@ impl UnifiedExecProcessManager { codex_utils_pty::pty::spawn_process_with_inherited_fds( program, args, - request.cwd.as_path(), + native_cwd.as_path(), &request.env, &request.arg0, codex_utils_pty::TerminalSize::default(), @@ -1143,7 +1215,7 @@ impl UnifiedExecProcessManager { codex_utils_pty::pipe::spawn_process_no_stdin_with_inherited_fds( program, args, - request.cwd.as_path(), + native_cwd.as_path(), &request.env, &request.arg0, &inherited_fds, @@ -1159,11 +1231,11 @@ impl UnifiedExecProcessManager { pub(super) async fn open_session_with_sandbox( &self, request: &ExecCommandRequest, - cwd: AbsolutePathBuf, + cwd: PathUri, context: &UnifiedExecContext, ) -> Result<(UnifiedExecProcess, Option), UnifiedExecError> { let local_policy_env = create_env( - &context.turn.shell_environment_policy, + &context.turn.config.permissions.shell_environment_policy, /*thread_id*/ None, ); let mut env = local_policy_env.clone(); @@ -1173,7 +1245,9 @@ impl UnifiedExecProcessManager { ); let env = apply_unified_exec_env(env); let exec_server_env_config = ExecServerEnvConfig { - policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy), + policy: exec_env_policy_from_shell_policy( + &context.turn.config.permissions.shell_environment_policy, + ), local_policy_env, }; let mut orchestrator = ToolOrchestrator::new(); @@ -1205,7 +1279,13 @@ impl UnifiedExecProcessManager { turn_environment: request.turn_environment.clone(), env, exec_server_env_config: Some(exec_server_env_config), - explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(), + explicit_env_overrides: context + .turn + .config + .permissions + .shell_environment_policy + .r#set + .clone(), network: request.network.clone(), tty: request.tty, sandbox_permissions: request.sandbox_permissions, 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 49758ec7998f..cd48d01c7cb4 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -76,9 +76,9 @@ 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(), + cwd: cwd.clone().into(), env: HashMap::from([ ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/sandbox-path".to_string()), @@ -98,25 +98,28 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ]), }), network: None, + network_environment_id: None, expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, sandbox: codex_sandboxing::SandboxType::None, - windows_sandbox_policy_cwd: cwd.clone(), + windows_sandbox_policy_cwd: cwd.clone().into(), 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 = exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); assert_eq!(params.process_id.as_str(), "123"); - assert_eq!(params.cwd, PathUri::from_abs_path(&request.cwd)); + assert_eq!(params.cwd, request.cwd); assert!(params.env_policy.is_some()); assert_eq!( params.env, @@ -125,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)] @@ -200,9 +208,9 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { yield_time_ms: 1000, max_output_tokens: None, #[allow(deprecated)] - cwd: turn.cwd.clone(), + cwd: turn.cwd.clone().into(), #[allow(deprecated)] - sandbox_cwd: turn.cwd.clone(), + sandbox_cwd: turn.cwd.clone().into(), turn_environment: turn .environments .primary() @@ -229,7 +237,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { &context, &request, #[allow(deprecated)] - turn.cwd.clone(), + turn.cwd.clone().into(), transcript, "PRE_DENIAL_MARKER".to_string(), "Network access denied".to_string(), diff --git a/codex-rs/core/src/unified_exec/process_state.rs b/codex-rs/core/src/unified_exec/process_state.rs index 267406da29ba..65e11b6f3e20 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 37ec5d858b22..db64d461ec66 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/src/user_shell_command.rs b/codex-rs/core/src/user_shell_command.rs index cf034faca00e..e64cf64cdfe4 100644 --- a/codex-rs/core/src/user_shell_command.rs +++ b/codex-rs/core/src/user_shell_command.rs @@ -11,7 +11,10 @@ fn user_shell_command_fragment( exec_output: &ExecToolCallOutput, turn_context: &TurnContext, ) -> UserShellCommand { - let output = format_exec_output_str(exec_output, turn_context.truncation_policy); + let output = format_exec_output_str( + exec_output, + turn_context.model_info.truncation_policy.into(), + ); UserShellCommand::new(command, exec_output.exit_code, exec_output.duration, output) } diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index a8f7823f9d8b..d693e7db7e47 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -7,6 +7,8 @@ use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use serde_json::Value; use serde_json::json; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; @@ -17,6 +19,7 @@ use wiremock::matchers::path; use wiremock::matchers::path_regex; const CONNECTOR_ID: &str = "calendar"; +pub const LINK_ID: &str = "link_calendar"; const CONNECTOR_NAME: &str = "Calendar"; const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; @@ -58,6 +61,13 @@ pub enum AppsTestToolLoading { Searchable, } +#[derive(Clone, Copy)] +enum AppsTestToolsListBehavior { + AlwaysAvailable, + AvailableAfterInitialList, + AlwaysUnavailable, +} + impl AppsTestServer { pub async fn mount(server: &MockServer) -> Result { Self::mount_with_connector_name(server, CONNECTOR_NAME).await @@ -72,6 +82,7 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, /*include_app_only_tool*/ false, + AppsTestToolsListBehavior::AlwaysAvailable, ) .await; Ok(Self { @@ -91,6 +102,7 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ false, /*include_app_only_tool*/ false, + AppsTestToolsListBehavior::AlwaysAvailable, ) .await; Ok(Self { @@ -110,6 +122,42 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), matches!(tool_loading, AppsTestToolLoading::Searchable), /*include_app_only_tool*/ true, + AppsTestToolsListBehavior::AlwaysAvailable, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + + pub async fn mount_with_tools_available_after_initial_list( + server: &MockServer, + ) -> Result { + Self::mount_with_tools_list_behavior( + server, + AppsTestToolsListBehavior::AvailableAfterInitialList, + ) + .await + } + + pub async fn mount_without_tools(server: &MockServer) -> Result { + Self::mount_with_tools_list_behavior(server, AppsTestToolsListBehavior::AlwaysUnavailable) + .await + } + + async fn mount_with_tools_list_behavior( + server: &MockServer, + tools_list_behavior: AppsTestToolsListBehavior, + ) -> Result { + mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc( + server, + CONNECTOR_NAME.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + /*searchable*/ false, + /*include_app_only_tool*/ false, + tools_list_behavior, ) .await; Ok(Self { @@ -267,6 +315,7 @@ async fn mount_streamable_http_json_rpc( connector_description: String, searchable: bool, include_app_only_tool: bool, + tools_list_behavior: AppsTestToolsListBehavior, ) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) @@ -275,6 +324,8 @@ async fn mount_streamable_http_json_rpc( connector_description, searchable, include_app_only_tool, + tools_list_behavior, + tools_list_calls: AtomicUsize::new(0), }) .mount(server) .await; @@ -285,6 +336,8 @@ struct CodexAppsJsonRpcResponder { connector_description: String, searchable: bool, include_app_only_tool: bool, + tools_list_behavior: AppsTestToolsListBehavior, + tools_list_calls: AtomicUsize, } impl Respond for CodexAppsJsonRpcResponder { @@ -330,6 +383,12 @@ impl Respond for CodexAppsJsonRpcResponder { } "notifications/initialized" => ResponseTemplate::new(202), "tools/list" => { + let list_index = self.tools_list_calls.fetch_add(1, Ordering::SeqCst); + let tools_available = match self.tools_list_behavior { + AppsTestToolsListBehavior::AlwaysAvailable => true, + AppsTestToolsListBehavior::AvailableAfterInitialList => list_index > 0, + AppsTestToolsListBehavior::AlwaysUnavailable => false, + }; let id = body.get("id").cloned().unwrap_or(Value::Null); let mut response = json!({ "jsonrpc": "2.0", @@ -356,6 +415,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, + "link_id": LINK_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "openai/outputTemplate": CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI, @@ -382,6 +442,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, + "link_id": LINK_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "_codex_apps": { @@ -414,6 +475,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, + "link_id": LINK_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "openai/fileParams": ["file"], @@ -428,7 +490,15 @@ impl Respond for CodexAppsJsonRpcResponder { "nextCursor": null } }); - if self.searchable + if !tools_available + && let Some(tools) = response + .pointer_mut("/result/tools") + .and_then(Value::as_array_mut) + { + tools.clear(); + } + if tools_available + && self.searchable && let Some(tools) = response .pointer_mut("/result/tools") .and_then(Value::as_array_mut) @@ -455,7 +525,8 @@ impl Respond for CodexAppsJsonRpcResponder { })); } } - if self.include_app_only_tool + if tools_available + && self.include_app_only_tool && let Some(tools) = response .pointer_mut("/result/tools") .and_then(Value::as_array_mut) diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 84a6908155a6..dd2bcc05308a 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -686,7 +686,7 @@ pub fn user_message_item(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -1251,71 +1251,73 @@ pub async fn start_websocket_server_with_headers( Ok(value) => value, Err(_) => return, }; + // Ordinary HTTP probes can share this listener with websocket tests. Only a + // successful websocket handshake should consume a scripted connection. let connection = { - let mut pending = pending_connections.lock().unwrap(); - pending.pop_front() + let pending = pending_connections.lock().unwrap(); + pending.front().cloned() }; let Some(connection) = connection else { continue; }; + if let Some(delay) = connection.accept_delay { + tokio::time::sleep(delay).await; + } + + let response_headers = connection.response_headers.clone(); + let handshake_log = Arc::clone(&logged_handshakes); + let callback = move |req: &Request, mut response: Response| { + let headers = req + .headers() + .iter() + .filter_map(|(name, value)| { + value.to_str().ok().map(|value| { + (name.as_str().to_string(), value.to_string()) + }) + }) + .collect(); + handshake_log.lock().unwrap().push(WebSocketHandshake { + uri: req.uri().to_string(), + headers, + }); + + let headers_mut = response.headers_mut(); + for (name, value) in &response_headers { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + headers_mut.insert(name, value); + } + } + + Ok(response) + }; + + let mut ws_stream = match accept_hdr_async_with_config( + stream, + callback, + Some(websocket_accept_config()), + ) + .await + { + Ok(ws) => ws, + Err(_) => continue, + }; + pending_connections.lock().unwrap().pop_front(); + let connection_index = { let mut log = logged_connections.lock().unwrap(); log.push(Vec::new()); log.len() - 1 }; let requests = Arc::clone(&logged_connections); - let handshakes = Arc::clone(&logged_handshakes); let request_log = Arc::clone(&request_log); let mut shutdown_signal = shutdown_signal_rx.clone(); connection_tasks.spawn(async move { - if let Some(delay) = connection.accept_delay { - tokio::time::sleep(delay).await; - } - - let response_headers = connection.response_headers.clone(); - let handshake_log = Arc::clone(&handshakes); - let callback = move |req: &Request, mut response: Response| { - let headers = req - .headers() - .iter() - .filter_map(|(name, value)| { - value.to_str().ok().map(|value| { - (name.as_str().to_string(), value.to_string()) - }) - }) - .collect(); - handshake_log.lock().unwrap().push(WebSocketHandshake { - uri: req.uri().to_string(), - headers, - }); - - let headers_mut = response.headers_mut(); - for (name, value) in &response_headers { - if let (Ok(name), Ok(value)) = ( - HeaderName::from_bytes(name.as_bytes()), - HeaderValue::from_str(value), - ) { - headers_mut.insert(name, value); - } - } - - Ok(response) - }; - - let mut ws_stream = match accept_hdr_async_with_config( - stream, - callback, - Some(websocket_accept_config()), - ) - .await - { - Ok(ws) => ws, - Err(_) => return, - }; - let close_after_requests = connection.close_after_requests; for request_events in connection.requests { let Some(Ok(message)) = ws_stream.next().await else { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index f3bb46efcdcc..0556b4f77804 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -15,7 +15,9 @@ use anyhow::Result; use anyhow::anyhow; use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; +use codex_core::StartThreadOptions; use codex_core::ThreadManager; +use codex_core::TimeProvider; use codex_core::config::Config; use codex_core::resolve_installation_id; use codex_core::shell::Shell; @@ -28,6 +30,7 @@ use codex_extension_api::ExtensionRegistry; use codex_extension_api::LoadUserInstructionsFuture; use codex_extension_api::UserInstructionsProvider; use codex_extension_api::empty_extension_registry; +use codex_features::Feature; use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -39,6 +42,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; @@ -55,6 +59,7 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::TempDirExt; +use crate::TestEnvironment; use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::load_default_config_for_test_with_cloud_config_bundle; @@ -176,7 +181,20 @@ pub async fn test_env() -> Result { /*sandbox*/ None, ) .await?; - let cwd = cwd_uri.to_abs_path()?; + let cwd = if remote_env == TestEnvironment::WineExec { + // TODO(anp): Convert `Config::cwd` to `LegacyAppPathString` and remove this + // compatibility projection. + // `Config::cwd` still requires `AbsolutePathBuf`. Preserve the test harness's + // Linux-absolute `/C:/...` compatibility spelling so converting it back to a + // `PathUri` recovers the remote Windows convention. Production conversions stay + // strict: `PathUri::to_abs_path` intentionally rejects foreign paths. + let path = cwd_uri.to_url().to_file_path().map_err(|()| { + anyhow!("remote test cwd URI cannot be projected onto the host: {cwd_uri}") + })?; + AbsolutePathBuf::try_from(path)? + } else { + cwd_uri.to_abs_path()? + }; Ok(TestEnv { environment, exec_server_url: Some(websocket_url), @@ -259,6 +277,8 @@ pub struct TestCodexBuilder { exec_server_url: Option, extensions: Arc>, user_instructions_provider: Option>, + supports_openai_form_elicitation: bool, + external_time_provider: Option>, } impl TestCodexBuilder { @@ -360,6 +380,16 @@ impl TestCodexBuilder { self } + pub fn with_openai_form_elicitation(mut self) -> Self { + self.supports_openai_form_elicitation = true; + self + } + + pub fn with_external_time_provider(mut self, provider: Arc) -> Self { + self.external_time_provider = Some(provider); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -571,6 +601,7 @@ impl TestCodexBuilder { state_db.clone(), installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ self.external_time_provider.clone(), ); let thread_manager = Arc::new(thread_manager); let user_shell_override = self.user_shell_override.clone(); @@ -585,6 +616,7 @@ impl TestCodexBuilder { path, auth_manager, user_shell_override, + self.supports_openai_form_elicitation, ), ) .await? @@ -596,6 +628,7 @@ impl TestCodexBuilder { path, auth_manager, /*parent_trace*/ None, + self.supports_openai_form_elicitation, )) .await? } @@ -605,11 +638,30 @@ impl TestCodexBuilder { thread_manager.as_ref(), config.clone(), user_shell_override, + self.supports_openai_form_elicitation, ), ) .await? } - (None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?, + (None, None) => { + let environments = thread_manager.default_environment_selections(&config.cwd); + Box::pin( + thread_manager.start_thread_with_options(StartThreadOptions { + config: config.clone(), + initial_history: InitialHistory::New, + session_source: None, + thread_source: None, + dynamic_tools: Vec::new(), + metrics_service_name: None, + multi_agent_mode: None, + parent_trace: None, + environments, + thread_extension_init: Default::default(), + supports_openai_form_elicitation: self.supports_openai_form_elicitation, + }), + ) + .await? + } }; Ok(TestCodex { @@ -1139,7 +1191,12 @@ fn function_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value { pub fn test_codex() -> TestCodexBuilder { TestCodexBuilder { - config_mutators: vec![], + config_mutators: vec![Box::new(|config| { + config + .features + .disable(Feature::Apps) + .expect("test config should allow Apps override"); + })], auth: CodexAuth::from_api_key("dummy"), use_config_auth_manager: false, pre_build_hooks: vec![], @@ -1150,6 +1207,8 @@ pub fn test_codex() -> TestCodexBuilder { exec_server_url: None, extensions: empty_extension_registry(), user_instructions_provider: None, + supports_openai_form_elicitation: false, + external_time_provider: None, } } diff --git a/codex-rs/core/tests/common/test_environment.rs b/codex-rs/core/tests/common/test_environment.rs index 386e4a0eebd6..ad2a335f56c8 100644 --- a/codex-rs/core/tests/common/test_environment.rs +++ b/codex-rs/core/tests/common/test_environment.rs @@ -1,7 +1,7 @@ use std::ffi::OsStr; use anyhow::Result; -use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; @@ -28,7 +28,7 @@ impl TestEnvironment { } } - pub(crate) fn remote_cwd(&self, instance_id: &str) -> Result> { + pub(crate) fn remote_cwd(&self, instance_id: &str) -> Result> { let path_uri = match self { Self::Local => return Ok(None), Self::Docker { .. } => { @@ -40,7 +40,7 @@ impl TestEnvironment { PathUri::parse(&format!("file:///C:/codex-core-test-cwd-{instance_id}"))? } }; - Ok(Some(ApiPathString::from_path_uri( + Ok(Some(LegacyAppPathString::from_path_uri( &path_uri, self.path_convention(), )?)) diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 24efb8e7ed92..04f8914982b1 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -2,9 +2,18 @@ use anyhow::Context; use anyhow::Result; +use app_test_support::PathBufExt; use app_test_support::TestAppServer; -use codex_app_server_protocol::JSONRPCError; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::Feature; @@ -26,26 +35,54 @@ use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; +use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; +use std::collections::BTreeMap; +use std::fs; use tempfile::TempDir; use tokio::time::timeout; use wine_exec_server_test_support::WineExecServer; const APP_SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -const CALL_ID: &str = "wine-cmd-smoke"; -const COMMAND: &str = r#"if ((Get-Location).Path -ne 'C:\windows') { exit 1 }"#; -const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { + const CALL_ID: &str = "wine-cmd-smoke"; + const PATCH_CALL_ID: &str = "wine-apply-patch"; + const VERIFY_CALL_ID: &str = "wine-verify-patch"; + const PATCH_FILE: &str = "codex-apply-patch-smoke.txt"; + const COMMAND: &str = r#"if ((Get-Location).Path -ne 'C:\windows') { exit 1 }"#; + const VERIFY_COMMAND: &str = r#"$path = Join-Path (Get-Location) 'codex-apply-patch-smoke.txt'; if (-not (Test-Path $path)) { exit 1 }; if ([IO.File]::ReadAllText($path) -ne "patched through unified exec`n") { exit 2 }; Remove-Item $path; Write-Output 'PATCH_VERIFIED'"#; + WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, _wine_prefix| async move { let server = start_mock_server().await; let arguments = serde_json::to_string(&json!({ "cmd": COMMAND, "login": false, + // An absolute foreign workdir should replace the selected environment cwd and + // reach exec-server without conversion to the host path convention. + "workdir": r"C:\windows", + "yield_time_ms": 10_000, + }))?; + let patch = format!( + "*** Begin Patch\n*** Add File: {PATCH_FILE}\n+patched through unified exec\n*** End Patch" + ); + let patch_arguments = serde_json::to_string(&json!({ + "cmd": format!("apply_patch <<'EOF'\n{patch}\nEOF\n"), + "login": false, + // Resolve this relative workdir using the selected Windows environment cwd. + "workdir": r"apply-patch-smoke\nested", + "yield_time_ms": 10_000, + }))?; + let verify_arguments = serde_json::to_string(&json!({ + "cmd": VERIFY_COMMAND, + "login": false, + "workdir": r"apply-patch-smoke\nested", "yield_time_ms": 10_000, }))?; let response_mock = mount_sse_sequence( @@ -58,9 +95,19 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { ]), sse(vec![ ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), + ev_function_call(PATCH_CALL_ID, "exec_command", &patch_arguments), ev_completed("resp-2"), ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call(VERIFY_CALL_ID, "exec_command", &verify_arguments), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-4"), + ]), ], ) .await; @@ -82,7 +129,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { test.config.cwd.clone(), vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::parse("file:///C:/windows")?, + cwd: PathUri::parse("file:///C:/codex-home")?, }], ); @@ -115,6 +162,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { let mut begin = None; let mut end = None; + let mut patch_end = None; let mut turn_complete = false; loop { match wait_for_event(&test.codex, |_| true).await { @@ -124,6 +172,9 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { EventMsg::ExecCommandEnd(event) if event.call_id == CALL_ID => { end = Some(event) } + EventMsg::PatchApplyEnd(event) if event.call_id == PATCH_CALL_ID => { + patch_end = Some(event) + } EventMsg::TurnComplete(_) => turn_complete = true, _ => {} } @@ -140,32 +191,84 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { "unexpected command: {:?}", begin.command ); - assert_eq!( - &begin.command[1..], - ["-NoProfile", "-Command", COMMAND] - ); + assert_eq!(&begin.command[1..], ["-NoProfile", "-Command", COMMAND]); let end = end.context("exec_command should emit an end event")?; + let expected_cwd = PathUri::parse("file:///C:/windows")?; + assert_eq!((&begin.cwd, &end.cwd), (&expected_cwd, &expected_cwd)); assert_eq!((end.exit_code, end.status), (0, ExecCommandStatus::Completed)); + let patch_end = patch_end.context("intercepted apply_patch should emit an end event")?; + assert!( + patch_end.success, + "intercepted apply_patch failed: stdout={:?} stderr={:?}", + patch_end.stdout, patch_end.stderr + ); + assert!( + patch_end + .changes + .contains_key(&std::path::PathBuf::from(format!( + r"C:\codex-home\apply-patch-smoke\nested\{PATCH_FILE}" + ))), + "apply_patch should retain the Windows cwd: {:?}", + patch_end.changes + ); let request = response_mock .last_request() .context("model should receive the command output")?; + let (verify_output, verify_success) = request + .function_call_output_content_and_success(VERIFY_CALL_ID) + .context("verification output should be present")?; + anyhow::ensure!( + verify_success != Some(false), + "verification command failed: {verify_output:?}" + ); + anyhow::ensure!( + verify_output + .as_deref() + .is_some_and(|output| output.contains("PATCH_VERIFIED")), + "verification command did not confirm the patched file: {verify_output:?}" + ); + let (_output, success) = request .function_call_output_content_and_success(CALL_ID) .context("command output should be present")?; assert_ne!(success, Some(false)); - + let (patch_output, patch_success) = request + .function_call_output_content_and_success(PATCH_CALL_ID) + .context("apply_patch output should be present")?; + let patch_output = patch_output.context("apply_patch output should contain text")?; + assert!(patch_output.contains(PATCH_FILE)); + assert_ne!(patch_success, Some(false)); Ok(()) }) .await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn app_server_rejects_windows_environment_cwd() -> Result<()> { +async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { + const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions"; + const NATIVE_CWD: &str = r"C:\windows"; + WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, wine_prefix| async move { + let agents_path = PathUri::parse("file:///C:/windows/AGENTS.md")?; + fs::write( + wine_prefix.join("drive_c").join("windows").join("AGENTS.md"), + AGENTS_INSTRUCTIONS, + )?; + let codex_home = TempDir::new()?; + let server = create_mock_responses_server_repeating_assistant("done").await; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + 100_000, + /*requires_openai_auth*/ None, + "mock", + "compact", + )?; let mut app_server = TestAppServer::new_with_env( codex_home.path(), &[( @@ -177,28 +280,114 @@ async fn app_server_rejects_windows_environment_cwd() -> Result<()> { timeout(APP_SERVER_READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server - .send_raw_request( - "thread/start", - Some(json!({ - "environments": [{ - "environmentId": REMOTE_ENVIRONMENT_ID, - "cwd": r"C:\windows", - }], - })), - ) + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: serde_json::from_value::(json!(NATIVE_CWD))?, + }]), + ..Default::default() + }) + .await?; + let response = timeout( + APP_SERVER_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadStartResponse = to_response(response)?; + assert!(!response.thread.id.is_empty()); + let host_cwd = codex_home.path().to_path_buf().abs(); + // TODO(anp): Return the selected environment's native cwd from thread/start. + assert_eq!(response.cwd, host_cwd); + // TODO(anp): Derive runtime workspace roots from the selected remote environment. + assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); + assert_eq!( + response.instruction_sources, + vec![LegacyAppPathString::from_path_uri( + &agents_path, + PathConvention::Windows, + )?] + ); + // TODO(anp): Report the implicit built-in permission profile instead of None. + assert_eq!(response.active_permission_profile, None); + + let turn_request_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: response.thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "say done".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) .await?; - let error: JSONRPCError = timeout( + let turn_response = timeout( APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_error_message(RequestId::Integer(request_id)), + app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + timeout( + APP_SERVER_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), ) .await??; - assert_eq!(error.id, RequestId::Integer(request_id)); - assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let first_request = requests + .iter() + .find(|request| request.url.path().ends_with("/responses")) + .context("turn should send a Responses request")?; + let body = first_request.body_json::()?; + let remote_instructions = body["input"] + .as_array() + .into_iter() + .flatten() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .find(|text| text.contains(AGENTS_INSTRUCTIONS)) + .context("remote workspace instructions should be model visible")?; + assert!(remote_instructions.contains(r"# AGENTS.md instructions for C:\windows")); + let environment_context = body["input"] + .as_array() + .into_iter() + .flatten() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .find(|text| text.starts_with("")) + .context("environment context should be model visible")?; + // The model should see the remote environment's shell, not the Linux app-server's + // host shell. assert_eq!( - error.error.message, - "Invalid request: AbsolutePathBuf deserialized without a base path" + environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim), + Some("powershell"), + ); + // The model should see cwd using the remote environment's native path convention, not + // the Linux app-server's host path convention. + assert_eq!( + environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim), + Some(r"C:\windows"), + ); + let host_workspace_roots = format!( + "{}", + codex_home.path().display() ); + // TODO(anp): Derive model-visible workspace roots from the selected remote environment + // and render them using its native path convention. + assert!(environment_context.contains(&host_workspace_roots)); Ok(()) }) diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index b5bc2b0d4440..2f323e0dd356 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -127,6 +127,7 @@ async fn responses_stream_includes_subagent_header_on_review() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -140,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 @@ -258,6 +259,7 @@ async fn responses_stream_includes_subagent_header_on_other() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -271,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 @@ -375,6 +377,7 @@ async fn responses_respects_model_info_overrides_from_config() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -388,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/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index d44b4e5a2370..dbb546f0d7a6 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -152,7 +152,7 @@ async fn interrupt_tool_records_history_entries() { let output = response_mock .function_call_output_text(call_id) .expect("missing function_call_output text"); - let re = Regex::new(r"^Wall time: ([0-9]+(?:\.[0-9])?) seconds\naborted by user$") + let re = Regex::new(r"^Wall time: ([0-9]+(?:\.[0-9]+)?) seconds\naborted by user$") .expect("compile regex"); let captures = re.captures(&output); assert_matches!( diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index a4ab404065dc..498a6660794a 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -25,7 +25,6 @@ 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::skip_if_no_network; -use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::RecordingUserInstructionsProvider; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; @@ -88,7 +87,7 @@ fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { } fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { - let cwd = cwd.as_path().display(); + let cwd = PathUri::from_abs_path(cwd).inferred_native_path_string(); format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") } @@ -138,8 +137,6 @@ fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn agents_override_is_preferred_over_agents_md() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let instructions = agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move { let agents_md = cwd.join("AGENTS.md"); @@ -172,8 +169,6 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let instructions = agents_instructions( test_codex() .with_config(|config| { @@ -334,8 +329,8 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> assert_eq!( test.codex.instruction_sources().await, vec![ - logical_root.join("AGENTS.md"), - test.config.cwd.join("AGENTS.md") + PathUri::from_abs_path(&logical_root.join("AGENTS.md")), + PathUri::from_abs_path(&test.config.cwd.join("AGENTS.md")) ] ); @@ -355,8 +350,6 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let server = start_mock_server().await; let resp_mock = mount_sse_once( &server, @@ -385,7 +378,10 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu assert_eq!( test.codex.instruction_sources().await, - vec![global_agents, project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); test.submit_turn("hello").await?; @@ -445,15 +441,17 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; assert_eq!(provider.load_count(), 2); assert_eq!( no_environment_thread.thread.instruction_sources().await, - vec![global_source] + vec![PathUri::from_abs_path(&global_source)] ); no_environment_thread @@ -484,8 +482,6 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); // Set up one global source, one project source, and two ordinary model turns. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( @@ -520,7 +516,10 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re }); let test = builder.build_with_remote_env(&server).await?; let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME); - let creation_sources = vec![global_source.clone(), project_source.clone()]; + let creation_sources = vec![ + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&project_source), + ]; // Confirm the thread records both creation-time sources in composition order. assert_eq!(test.codex.instruction_sources().await, creation_sources); @@ -596,8 +595,6 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapshot() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); skip_if_no_network!(Ok(())); let Some(_remote_env) = get_remote_test_env() else { return Ok(()); @@ -652,6 +649,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: vec![ TurnEnvironmentSelection { @@ -664,15 +662,16 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho }, ], thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; assert_eq!(provider.load_count(), 2); assert_eq!( thread.thread.instruction_sources().await, vec![ - global_source.clone(), - remote_source.clone(), - local_source.clone().try_into()?, + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&remote_source), + PathUri::from_path(&local_source)?, ] ); @@ -698,7 +697,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho let contents = format!( "{GLOBAL_INSTRUCTIONS}\n\nfor `{REMOTE_ENVIRONMENT_ID}` with root {}\n\nremote project instructions\n\nfor `{LOCAL_ENVIRONMENT_ID}` with root {}\n\nlocal project instructions", - test.config.cwd.display(), + PathUri::from_abs_path(&test.config.cwd).inferred_native_path_string(), local_root.path().display(), ); let expected = @@ -710,7 +709,11 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho assert_eq!(provider.load_count(), 2); assert_eq!( thread.thread.instruction_sources().await, - vec![global_source, remote_source, local_source.try_into()?] + vec![ + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&remote_source), + PathUri::from_path(&local_source)?, + ] ); Ok(()) @@ -739,7 +742,10 @@ async fn invalid_utf8_global_instructions_are_lossy() -> Result<()> { test.submit_turn("inspect lossy global instructions") .await?; - assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); + assert_eq!( + test.codex.instruction_sources().await, + vec![PathUri::from_abs_path(&source)] + ); let expected_fragment = expected_provider_only_instruction_fragment("global\u{FFFD}instructions"); assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); @@ -782,7 +788,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so // Assert the pre-resume thread reports the source used to create its snapshot. assert_eq!( initial.codex.instruction_sources().await, - vec![old_source.clone()], + vec![PathUri::from_abs_path(&old_source)], "initial thread reports the creation-time global source" ); initial.submit_turn("persist instructions").await?; @@ -812,7 +818,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so // Assert the API reports the new source while model history replays the old structured prefix. assert_eq!( resumed.codex.instruction_sources().await, - vec![new_source], + vec![PathUri::from_abs_path(&new_source)], "resume reports sources from the newly loaded config" ); @@ -866,7 +872,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> // Assert the parent reports the source used to create its snapshot. assert_eq!( parent.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "parent reports the creation-time global source" ); parent.submit_turn("persist instructions").await?; @@ -901,7 +907,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> // Assert the fork reports the new source before issuing its first turn. assert_eq!( forked.thread.instruction_sources().await, - vec![new_source], + vec![PathUri::from_abs_path(&new_source)], "fork config should reflect the newly loaded global source" ); @@ -1031,7 +1037,7 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> // Assert the parent reports the creation-time source before spawning. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "parent reports the creation-time global source before spawning" ); test.submit_turn(SPAWN_SEED_PROMPT).await?; @@ -1074,12 +1080,12 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> assert_single_instruction_fragment(&child_request, &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "running parent retains the creation-time global source after spawning" ); assert_eq!( child_thread.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "subagent reports the parent's creation-time source" ); if fork_context { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 422fd2826073..9e1ba523b38a 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -754,12 +754,16 @@ async fn expect_patch_approval( test: &TestCodex, expected_call_id: &str, ) -> ApplyPatchApprovalRequestEvent { - let event = wait_for_event(&test.codex, |event| { - matches!( - event, - EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_) - ) - }) + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + Duration::from_secs(/*secs*/ 30), + ) .await; match event { @@ -791,9 +795,11 @@ async fn wait_for_completion_without_approval(test: &TestCodex) { } async fn wait_for_completion(test: &TestCodex) { - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) + wait_for_event_with_timeout( + &test.codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + Duration::from_secs(/*secs*/ 60), + ) .await; } @@ -1380,7 +1386,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "apply_patch_shell_command_requires_patch_approval", approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), + sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchShell { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", @@ -1494,7 +1500,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "apply_patch_freeform_unless_trusted_requires_patch_approval", approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), + sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchFreeform { target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"), content: "freeform-patch-unless-trusted", @@ -2060,13 +2066,8 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let approval_policy = AskForApproval::UnlessTrusted; + let sandbox_policy = SandboxPolicy::DangerFullAccess; let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 4dd3effe4906..8cee891f66a9 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -36,8 +36,10 @@ 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; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; use pretty_assertions::assert_eq; use serde_json::json; +use std::time::Duration; use wiremock::MockServer; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -194,7 +196,12 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event_with_timeout( + &codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + Duration::from_secs(/*secs*/ 60), + ) + .await; let guardian_request = responses .requests() diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c72c46f1970d..7c9a0cb41515 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -60,6 +60,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_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_response_created; @@ -188,6 +189,22 @@ fn response_style_artifact_schema() -> serde_json::Value { }) } +fn response_message_item_id(request: &ResponsesRequest, role: &str, text: &str) -> String { + request + .inputs_of_type("message") + .into_iter() + .find(|item| { + item.get("role").and_then(serde_json::Value::as_str) == Some(role) + && message_input_texts(item).contains(&text) + }) + .and_then(|item| { + item.get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("missing item ID for {role} message {text:?}")) +} + fn assert_codex_client_metadata( request_body: &serde_json::Value, installation_id: &str, @@ -223,7 +240,7 @@ 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 non_openai_responses_requests_omit_item_passthrough_metadata() { let server = MockServer::start().await; let response_mock = mount_sse_once( &server, @@ -267,10 +284,133 @@ 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(), + "input item should omit generated IDs: {item}" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> anyhow::Result<()> { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg_server", "first reply"), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut builder = test_codex().with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); + let initial = builder.build(&server).await?; + let home = Arc::clone(&initial.home); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial.submit_turn("before resume").await?; + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + builder = builder.with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); + let resumed = builder.resume(&server, home, rollout_path).await?; + resumed.submit_turn("after resume").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let user_id = response_message_item_id(&requests[0], "user", "before resume"); + let user_uuid = user_id + .strip_prefix("msg_") + .expect("message ID should have the Responses API prefix"); + assert_eq!( + Uuid::parse_str(user_uuid)?.get_version(), + Some(uuid::Version::SortRand) + ); + assert_eq!( + response_message_item_id(&requests[1], "user", "before resume"), + user_id + ); + assert_eq!( + response_message_item_id(&requests[1], "assistant", "first reply"), + "msg_server" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_item_ids_are_sent_for_all_remote_v2_compaction_requests() -> anyhow::Result<()> { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", + } + }), + ev_completed("resp-compact"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + let _ = config.features.enable(Feature::RemoteCompactionV2); + }) + .build(&server) + .await?; + + test.submit_turn("before compaction").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after compaction").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + for (request_index, request) in requests.iter().enumerate() { + let input = request.input(); + assert!(!input.is_empty(), "request {request_index} input is empty"); + for item in input { + if item.get("type").and_then(serde_json::Value::as_str) == Some("compaction_trigger") { + continue; + } + assert!( + item.get("id").and_then(serde_json::Value::as_str).is_some(), + "request {request_index} item should have an ID: {item:#?}" + ); + } } + + Ok(()) } /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. @@ -431,6 +571,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", @@ -451,7 +592,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!( @@ -473,7 +614,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!( @@ -495,7 +636,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!( @@ -631,15 +772,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(), @@ -658,10 +801,11 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { RolloutLine { timestamp: "2024-01-01T00:00:02.000Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { + id: None, 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 { @@ -674,7 +818,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, }), }, ]; @@ -764,15 +908,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(), @@ -790,14 +936,15 @@ 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 { timestamp: "2024-01-01T00:00:01.500Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + id: None, call_id: function_call_id.to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -805,7 +952,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -816,12 +963,13 @@ 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 { timestamp: "2024-01-01T00:00:02.500Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { + id: None, call_id: custom_call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -830,7 +978,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, ]; @@ -1059,6 +1207,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); @@ -1071,7 +1220,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 @@ -1276,6 +1425,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") @@ -1296,6 +1446,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { /*state_db*/ None, installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config.clone()) @@ -1575,6 +1726,123 @@ async fn omits_apps_guidance_when_configured_off() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn omits_apps_guidance_when_orchestrator_mcp_is_disabled() { + skip_if_no_network!(); + let server = MockServer::start().await; + let apps_server = AppsTestServer::mount(&server) + .await + .expect("mount apps MCP mock"); + let apps_base_url = apps_server.chatgpt_base_url.clone(); + + let list_call_id = "list-resources"; + let read_call_id = "read-resource"; + let resp_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp1"), + ev_function_call(list_call_id, "list_mcp_resources", "{}"), + ev_completed("resp1"), + ]), + sse(vec![ + ev_response_created("resp2"), + ev_function_call( + read_call_id, + "read_mcp_resource", + &json!({ + "server": "codex_apps", + "uri": "skill://demo/SKILL.md", + }) + .to_string(), + ), + ev_completed("resp2"), + ]), + sse(vec![ev_response_created("resp3"), ev_completed("resp3")]), + ], + ) + .await; + + let mut builder = test_codex() + .with_auth(create_dummy_codex_auth()) + .with_config(move |config| { + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config.chatgpt_base_url = apps_base_url; + config.orchestrator_mcp_enabled = false; + }); + let codex = builder + .build(&server) + .await + .expect("create new conversation") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = resp_mock.requests(); + assert_eq!(requests.len(), 3); + let request = &requests[0]; + assert!( + !message_input_text_contains(request, "developer", ""), + "did not expect apps instructions when orchestrator MCP is disabled, got {:?}", + request.body_json()["input"] + ); + assert!( + !request.body_contains_text("mcp__codex_apps"), + "did not expect codex_apps MCP tools when orchestrator MCP is disabled, got {:?}", + request.body_json()["tools"] + ); + let list_output = requests[1] + .function_call_output_text(list_call_id) + .expect("resource list output should be sent to the model"); + assert_eq!( + serde_json::from_str::(&list_output) + .expect("parse resource list output"), + json!({"resources": []}) + ); + let read_output = requests[2] + .function_call_output_text(read_call_id) + .expect("resource read output should be sent to the model"); + assert!( + read_output.contains("disabled by `orchestrator.mcp.enabled`"), + "unexpected resource read output: {read_output}" + ); + + let resource_methods = server + .received_requests() + .await + .expect("read recorded requests") + .into_iter() + .filter_map(|request| serde_json::from_slice::(&request.body).ok()) + .filter_map(|body| { + body.get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .filter(|method| method.starts_with("resources/")) + .collect::>(); + assert!( + resource_methods.is_empty(), + "did not expect codex_apps resource calls: {resource_methods:?}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn omits_environment_context_when_configured_off() { let server = MockServer::start().await; @@ -2788,6 +3056,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); @@ -2795,7 +3064,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut prompt = Prompt::default(); prompt.input.push(ResponseItem::Reasoning { - id: "reasoning-id".into(), + id: Some("reasoning-id".into()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".into(), }], @@ -2803,7 +3072,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()), @@ -2812,7 +3081,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()), @@ -2821,7 +3090,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()), @@ -2829,12 +3098,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()), @@ -2847,7 +3117,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()), @@ -2855,13 +3125,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 diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index cee54e24b310..5d1721ee75fa 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -157,7 +157,8 @@ async fn responses_websocket_streams_request() { let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); - let prompt = prompt_with_input(vec![message_item("hello")]); + let mut prompt = prompt_with_input(vec![message_item("hello")]); + prompt.input[0].set_id(Some("msg_existing".to_string())); stream_until_complete(&mut client_session, &harness, &prompt).await; @@ -169,6 +170,7 @@ async fn responses_websocket_streams_request() { assert_eq!(body["model"].as_str(), Some(MODEL)); assert_eq!(body["stream"], serde_json::Value::Bool(true)); assert_eq!(body["input"].as_array().map(Vec::len), Some(1)); + assert_eq!(body["input"][0].get("id"), None); let handshake = server.single_handshake(); assert_eq!( handshake.header(OPENAI_BETA_HEADER), @@ -2057,7 +2059,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, } } @@ -2067,7 +2069,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, } } @@ -2189,6 +2191,7 @@ async fn websocket_harness_with_provider_options( /*enable_request_compression*/ false, runtime_metrics_enabled, /*beta_features_header*/ None, + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index c1fa6b2e293f..2d8b5219ee42 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -6,7 +6,9 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; +use codex_core::config::CurrentTimeReminderConfig; use codex_extension_api::ExtensionRegistryBuilder; +use codex_features::CurrentTimeSource; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; @@ -219,6 +221,19 @@ async fn run_code_mode_turn_with_model_and_config( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_call_standalone_web_search() -> Result<()> { + assert_code_mode_standalone_web_search(WebSearchMode::Live, serde_json::json!(true)).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_call_indexed_standalone_web_search() -> Result<()> { + assert_code_mode_standalone_web_search(WebSearchMode::Indexed, serde_json::json!("indexed")) + .await +} + +async fn assert_code_mode_standalone_web_search( + web_search_mode: WebSearchMode, + expected_external_web_access: Value, +) -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -266,7 +281,7 @@ text(result); .with_auth(auth) .with_extensions(Arc::new(extension_builder.build())) .with_model("test-gpt-5.1-codex") - .with_config(|config| { + .with_config(move |config| { config .features .enable(Feature::CodeMode) @@ -277,7 +292,7 @@ text(result); .expect("standalone web search should be enabled"); config .web_search_mode - .set(WebSearchMode::Live) + .set(web_search_mode) .expect("web search mode should be accepted"); }); let test = builder.build(&server).await?; @@ -308,7 +323,7 @@ text(result); search_body["settings"], serde_json::json!({ "allowed_callers": ["direct"], - "external_web_access": true, + "external_web_access": expected_external_web_access, }) ); assert_eq!( @@ -852,6 +867,49 @@ text(JSON.stringify(result)); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_current_time_returns_structured_result() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn_with_config( + &server, + "use exec to get the current time", + r#" +const result = await tools.clock__curr_time({}); +text(JSON.stringify(result)); +"#, + |config| { + config + .features + .enable(Feature::CurrentTimeReminder) + .expect("test config should allow current-time reminders"); + config.current_time_reminder = Some(CurrentTimeReminderConfig { + reminder_interval_model_requests: 50, + clock_source: CurrentTimeSource::System, + }); + }, + ) + .await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "exec clock.curr_time call failed unexpectedly: {output}" + ); + + let parsed: Value = serde_json::from_str(&output)?; + let current_time = parsed + .get("current_time") + .and_then(Value::as_str) + .expect("clock.curr_time should return current_time"); + assert_regex_match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$", current_time); + + Ok(()) +} + #[cfg_attr(windows, ignore = "flaky on windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_nested_tool_calls_can_run_in_parallel() -> Result<()> { @@ -944,9 +1002,15 @@ text(JSON.stringify(results)); Ok(()) } +// This model uses token-based tool-output truncation, giving the downstream +// history assertions a stable `…N tokens truncated…` marker. +const TOKEN_POLICY_TEST_MODEL: &str = "gpt-5.4"; + +// A nested `exec_command` limit applies to `result.output` inside JavaScript. +// The outer code-mode and history budgets apply after the script calls `text`. #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_command_explicit_max_output_tokens_truncates() -> Result<()> { +async fn code_mode_exec_nested_limit_formats_truncated_result_with_warning() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -968,7 +1032,7 @@ text(result.output); &custom_tool_output_items(&second_mock.single_request(), "call-1"), /*index*/ 1 ), - "Total output lines: 1\n\n0123456789…5 tokens truncated…0123456789" + "Warning: truncated output (original token count: 10)\nTotal output lines: 1\n\n0123456789…5 tokens truncated…0123456789" ); Ok(()) @@ -976,7 +1040,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_above_default_preserves_output() -> Result<()> { +async fn code_mode_exec_nested_limit_preserves_result_variable_before_default_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -985,7 +1050,7 @@ async fn code_mode_exec_explicit_max_above_default_preserves_output() -> Result< skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} @@ -993,17 +1058,22 @@ const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"", max_output_tokens: 20000 }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, + |_| {}, ) .await?; + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + output, + format!( + "Variable truncated: False. Variable: {}", + "x".repeat(50_000) + ) ); Ok(()) @@ -1011,7 +1081,7 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_above_default_truncates_larger_output() -> Result<()> { +async fn code_mode_exec_nested_limit_truncates_result_variable_when_exceeded() -> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1020,7 +1090,7 @@ async fn code_mode_exec_explicit_max_above_default_truncates_larger_output() -> skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 25000} @@ -1028,18 +1098,20 @@ const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('A' * 90000)\"", max_output_tokens: 20000 }); -text(result.output); +const resultVariableWasTruncated = result.output.includes("…2500 tokens truncated…"); +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, + |_| {}, ) .await?; + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), + output, format!( - "Total output lines: 1\n\n{}…2500 tokens truncated…{}", + "Variable truncated: True. Variable: Warning: truncated output (original token count: 22500)\nTotal output lines: 1\n\n{}…2500 tokens truncated…{}", "A".repeat(40_000), "A".repeat(40_000) ) @@ -1050,7 +1122,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_above_truncation_policy_preserves_output() -> Result<()> { +async fn code_mode_exec_nested_limit_preserves_result_variable_before_configured_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1059,7 +1132,7 @@ async fn code_mode_exec_explicit_max_above_truncation_policy_preserves_output() 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_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} @@ -1067,20 +1140,24 @@ const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"", max_output_tokens: 20000 }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, |config| { config.tool_output_token_limit = Some(50); }, ) .await?; + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + output, + format!( + "Variable truncated: False. Variable: {}", + "x".repeat(50_000) + ) ); Ok(()) @@ -1088,7 +1165,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_without_max_preserves_output_beyond_default() -> Result<()> { +async fn code_mode_exec_without_nested_limit_preserves_result_variable_before_default_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1097,24 +1175,29 @@ async fn code_mode_exec_without_max_preserves_output_beyond_default() -> Result< skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn( + let (_test, second_mock) = run_code_mode_turn_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"" }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, + |_| {}, ) .await?; + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + output, + format!( + "Variable truncated: False. Variable: {}", + "x".repeat(50_000) + ) ); Ok(()) @@ -1122,7 +1205,8 @@ text(result.output); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_without_max_preserves_output_beyond_truncation_policy() -> Result<()> { +async fn code_mode_exec_without_nested_limit_preserves_result_variable_before_configured_history_truncation() +-> Result<()> { // TODO(anp): Remove after Wine exec returns complete nested-tool output to code mode. skip_if_wine_exec!( Ok(()), @@ -1131,35 +1215,41 @@ async fn code_mode_exec_without_max_preserves_output_beyond_truncation_policy() 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_with_model_and_config( &server, "use exec_command from code mode", r#"// @exec: {"max_output_tokens": 20000} const result = await tools.exec_command({ cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"" }); -text(result.output); +const resultVariableWasTruncated = result.output.length !== 50000; +text(`Variable truncated: ${resultVariableWasTruncated ? "True" : "False"}. Variable: ${result.output}`); "#, + TOKEN_POLICY_TEST_MODEL, |config| { config.tool_output_token_limit = Some(50); }, ) .await?; + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); + let output = text_item(&items, /*index*/ 1); assert_eq!( - text_item( - &custom_tool_output_items(&second_mock.single_request(), "call-1"), - /*index*/ 1 - ), - "x".repeat(50_000) + output, + format!( + "Variable truncated: False. Variable: {}", + "x".repeat(50_000) + ) ); Ok(()) } +// The outer directive limits output after JavaScript emits it; it does not +// limit `result.output` returned by the nested command. #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_explicit_max_output_tokens_truncates() -> Result<()> { +async fn code_mode_exec_outer_limit_truncates_emitted_output() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -1180,7 +1270,7 @@ text(result.output); &custom_tool_output_items(&second_mock.single_request(), "call-1"), /*index*/ 1 ), - "Total output lines: 1\n\n0123456789…5 tokens truncated…0123456789" + "Warning: truncated output (original token count: 10)\nTotal output lines: 1\n\n0123456789…5 tokens truncated…0123456789" ); Ok(()) @@ -1415,7 +1505,7 @@ text("phase 3"); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_yield_timeout_works_for_busy_loop() -> Result<()> { +async fn code_mode_yield_and_termination_are_not_starved_by_runtime_output() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -1424,8 +1514,12 @@ async fn code_mode_yield_timeout_works_for_busy_loop() -> Result<()> { }); let test = builder.build(&server).await?; - let code = r#"// @exec: {"yield_time_ms": 100} -text("phase 1"); + // Exact controller arbitration is covered by deterministic code-mode contract tests. Keep + // this end-to-end load bounded while exercising a substantial runtime output backlog. + let code = r#"// @exec: {"yield_time_ms": 0, "max_output_tokens": 16} +for (let index = 0; index < 16_384; index++) { + text(`event ${index}`); +} while (true) {} "#; @@ -1455,7 +1549,7 @@ while (true) {} let first_request = first_completion.single_request(); let first_items = custom_tool_output_items(&first_request, "call-1"); - assert_eq!(first_items.len(), 2); + assert_eq!(first_items.len(), 1); assert_regex_match( concat!( r"(?s)\A", @@ -1463,7 +1557,6 @@ while (true) {} ), text_item(&first_items, /*index*/ 0), ); - assert_eq!(text_item(&first_items, /*index*/ 1), "phase 1"); let cell_id = extract_running_cell_id(text_item(&first_items, /*index*/ 0)); responses::mount_sse_once( @@ -1495,7 +1588,7 @@ while (true) {} let second_request = second_completion.single_request(); let second_items = function_tool_output_items(&second_request, "call-2"); - assert_eq!(second_items.len(), 1); + assert!(!second_items.is_empty()); assert_regex_match( concat!( r"(?s)\A", @@ -2393,6 +2486,7 @@ text("token one token two token three token four token five token six token seve ); let expected_pattern = r#"(?sx) \A +Warning:\ truncated\ output\ \(original\ token\ count:\ \d+\)\n Total\ output\ lines:\ 1\n \n .*…\d+\ tokens\ truncated….* @@ -2584,7 +2678,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?; @@ -2609,7 +2703,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" }), ); @@ -2618,17 +2712,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?; @@ -2649,7 +2740,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); @@ -2675,12 +2766,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 cff88f57a8b9..dec106d0d84d 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; @@ -25,6 +26,7 @@ use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -34,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; @@ -91,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, @@ -1992,11 +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 = @@ -2274,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!(); @@ -3653,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!(); @@ -3991,11 +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 = @@ -4118,11 +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 = @@ -4229,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 { @@ -4484,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!(); @@ -4590,7 +4802,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "thread reports the creation-time global source before compaction" ); @@ -4620,7 +4832,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu assert_single_instruction_fragment(&requests[2], &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "thread retains the creation-time global source after compaction" ); @@ -4670,7 +4882,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "thread reports the creation-time global source before mid-turn compaction" ); @@ -4692,7 +4904,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re assert_single_instruction_fragment(&requests[2], &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "thread retains the creation-time global source after mid-turn compaction" ); @@ -4777,7 +4989,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m ); assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "running thread retains the selected same-path source" ); assert_eq!( @@ -4826,7 +5038,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m ); assert_eq!( resumed.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "cold-resumed thread reports the same rewritten source path" ); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index f9b35f686a35..ececb02b2249 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -157,8 +157,9 @@ fn format_labeled_requests_snapshot( 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, }] } @@ -202,9 +203,10 @@ async fn start_remote_realtime_server() -> responses::WebSocketTestServer { async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<()> { codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -329,8 +331,9 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await; 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(), @@ -2356,8 +2359,9 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> let compacted_history = vec![ ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -2366,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( @@ -2508,11 +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( @@ -2650,11 +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( @@ -4059,8 +4065,9 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject .await; 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_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 301f5a978a06..2776e407afe9 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -885,6 +885,7 @@ async fn resume_conversation( path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, )) .await .expect("resume conversation") diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs new file mode 100644 index 000000000000..fda9b85b03f2 --- /dev/null +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -0,0 +1,317 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; + +use anyhow::Result; +use anyhow::anyhow; +use chrono::DateTime; +use chrono::Utc; +use codex_core::TimeFuture; +use codex_core::TimeProvider; +use codex_core::config::CurrentTimeReminderConfig; +use codex_features::CurrentTimeSource; +use codex_features::Feature; +use codex_model_provider_info::built_in_model_providers; +use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::assert_regex_match; +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::mount_sse_once; +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::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; + +const FIRST_REMINDER: &str = "It is 2026-06-17 17:34:15 UTC."; +const SECOND_REMINDER: &str = "It is 2026-06-17 17:35:15 UTC."; +const FIRST_TIME_UNIX_SECONDS: i64 = 1_781_717_655; + +struct TestTimeProvider(AtomicI64); + +impl Default for TestTimeProvider { + fn default() -> Self { + Self(AtomicI64::new(FIRST_TIME_UNIX_SECONDS)) + } +} + +impl TimeProvider for TestTimeProvider { + fn current_time(&self, _thread_id: ThreadId) -> TimeFuture<'_> { + let timestamp = self.0.fetch_add(60, Ordering::Relaxed); + Box::pin(async move { + Ok(DateTime::::from_timestamp(timestamp, 0) + .expect("test timestamp should be valid")) + }) + } +} + +struct FailingTimeProvider; + +impl TimeProvider for FailingTimeProvider { + fn current_time(&self, _thread_id: ThreadId) -> TimeFuture<'_> { + Box::pin(async { Err(anyhow!("test clock unavailable")) }) + } +} + +fn current_time_reminders(request: &ResponsesRequest) -> Vec { + request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("It is ")) + .collect() +} + +fn enable_current_time_reminder( + config: &mut codex_core::config::Config, + interval: u64, + clock_source: CurrentTimeSource, +) { + config + .features + .enable(Feature::CurrentTimeReminder) + .expect("test config should allow current-time reminders"); + config.current_time_reminder = Some(CurrentTimeReminderConfig { + reminder_interval_model_requests: interval, + clock_source, + }); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn current_time_reminders_follow_request_interval_and_persist_in_history() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_args = json!({ + "command": "echo current time", + "timeout_ms": 1_000, + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + "current-time-tool-call", + "shell_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 2, CurrentTimeSource::External) + }) + .with_external_time_provider(Arc::new(TestTimeProvider::default())) + .build(&server) + .await?; + + test.submit_turn_with_permission_profile("first turn", PermissionProfile::Disabled) + .await?; + test.submit_turn("second turn").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 3); + assert_eq!(current_time_reminders(&requests[0]), vec![FIRST_REMINDER]); + assert_eq!(current_time_reminders(&requests[1]), vec![FIRST_REMINDER]); + assert_eq!( + current_time_reminders(&requests[2]), + vec![FIRST_REMINDER, SECOND_REMINDER] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn system_time_source_adds_current_time_reminder() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 1, CurrentTimeSource::System) + }) + .build(&server) + .await?; + + test.submit_turn("what time is it?").await?; + + let reminders = current_time_reminders(&responses.single_request()); + assert_eq!(reminders.len(), 1); + assert_regex_match( + r"^It is \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\.$", + &reminders[0], + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn current_time_reminder_is_refreshed_after_compaction() -> 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")]), + sse(vec![ + ev_response_created("resp-compact"), + ev_assistant_message("msg-compact", "compact summary"), + ev_completed("resp-compact"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + model_provider.name = "OpenAI-compatible test provider".to_string(); + model_provider.base_url = Some(format!("{}/v1", server.uri())); + model_provider.supports_websockets = false; + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + enable_current_time_reminder(config, /*interval*/ 50, CurrentTimeSource::External); + }) + .with_external_time_provider(Arc::new(TestTimeProvider::default())) + .build(&server) + .await?; + + test.submit_turn("before compact").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after compact").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 3); + assert_eq!( + current_time_reminders(&requests[2]), + vec![SECOND_REMINDER], + "a new context window should force a fresh reminder before the next model request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn time_provider_failure_stops_before_inference() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ + ev_response_created("unused-response"), + ev_completed("unused-response"), + ]), + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 1, CurrentTimeSource::External) + }) + .with_external_time_provider(Arc::new(FailingTimeProvider)) + .build(&server) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "fail before inference".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let EventMsg::Error(error) = + wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await + else { + unreachable!(); + }; + assert_eq!( + error.message, + "Fatal error: failed to read current time: test clock unavailable" + ); + assert_eq!(error.codex_error_info, Some(CodexErrorInfo::Other)); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + assert!(responses.requests().is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn current_time_tool_returns_the_latest_time() -> Result<()> { + skip_if_no_network!(Ok(())); + + const CALL_ID: &str = "current-time"; + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace(CALL_ID, "clock", "curr_time", "{}"), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 50, CurrentTimeSource::External) + }) + .with_external_time_provider(Arc::new(TestTimeProvider::default())) + .build(&server) + .await?; + + test.submit_turn("check the current time").await?; + + let requests = responses.requests(); + assert!( + requests[0].tool_by_name("clock", "curr_time").is_some(), + "clock.curr_time should be exposed when current-time reminders are enabled" + ); + assert_eq!( + requests[1].function_call_output_text(CALL_ID), + Some(SECOND_REMINDER.to_string()) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index c41ff47f2a2f..9248486f53aa 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -92,7 +92,7 @@ async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow assert_eq!( details.as_deref(), Some( - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." + "Set `web_search` to `\"live\"`, `\"indexed\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." ), ); } diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 84d7408b815d..355711e34516 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -41,6 +41,7 @@ where capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/core/tests/suite/extension_sandbox.rs b/codex-rs/core/tests/suite/extension_sandbox.rs index e4c01557deef..aa835fa9ac8b 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 a1fdf79a8ea5..d51ba7d2f61e 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -201,6 +201,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { }), /*thread_source*/ None, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("fork from stored history"); diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index ae87350c397e..c1da2620332a 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/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs deleted file mode 100644 index f017d7d42b39..000000000000 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ /dev/null @@ -1,100 +0,0 @@ -use codex_features::Feature; -use codex_utils_path_uri::PathUri; -use core_test_support::responses::ev_completed; -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::skip_if_wine_exec; -use core_test_support::test_codex::test_codex; - -const HIERARCHICAL_AGENTS_SNIPPET: &str = - "Files called AGENTS.md commonly appear in many places inside a container"; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!("requires native cross-OS instruction-source paths"); - let server = start_mock_server().await; - let resp_mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), - ) - .await; - - let mut builder = test_codex() - .with_config(|config| { - config - .features - .enable(Feature::ChildAgentsMd) - .expect("test config should allow feature update"); - }) - .with_workspace_setup(|cwd, fs| async move { - let agents_md = cwd.join("AGENTS.md"); - let agents_md_uri = PathUri::from_path(&agents_md)?; - fs.write_file(&agents_md_uri, b"be nice".to_vec(), /*sandbox*/ None) - .await?; - Ok::<(), anyhow::Error>(()) - }); - let test = builder - .build_with_remote_env(&server) - .await - .expect("build test codex"); - - test.submit_turn("hello").await.expect("submit turn"); - - let request = resp_mock.single_request(); - let user_messages = request.message_input_texts("user"); - let instructions = user_messages - .iter() - .find(|text| text.starts_with("# AGENTS.md instructions")) - .expect("instructions message"); - assert!( - instructions.contains("be nice"), - "expected AGENTS.md text included: {instructions}" - ); - let snippet_pos = instructions - .find(HIERARCHICAL_AGENTS_SNIPPET) - .expect("expected hierarchical agents snippet"); - let base_pos = instructions - .find("be nice") - .expect("expected AGENTS.md text"); - assert!( - snippet_pos > base_pos, - "expected hierarchical agents message appended after base instructions: {instructions}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hierarchical_agents_emits_when_no_project_doc() { - let server = start_mock_server().await; - let resp_mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), - ) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ChildAgentsMd) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_remote_env(&server) - .await - .expect("build test codex"); - - test.submit_turn("hello").await.expect("submit turn"); - - let request = resp_mock.single_request(); - let user_messages = request.message_input_texts("user"); - let instructions = user_messages - .iter() - .find(|text| text.starts_with("# AGENTS.md instructions")) - .expect("instructions message"); - assert!( - instructions.contains(HIERARCHICAL_AGENTS_SNIPPET), - "expected hierarchical agents message appended: {instructions}" - ); -} diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 2b18559075b4..f644f334047b 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -178,7 +178,7 @@ 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); @@ -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,7 +269,7 @@ 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); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index a39a2b5e059c..515d496b8b7a 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -348,7 +348,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { +async fn builtin_image_generation_call_persisted() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -369,7 +369,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ ev_response_created("resp-1"), - ev_image_generation_call(call_id, "completed", "A tiny blue square", "Zm9v"), + ev_image_generation_call(call_id, "generating", "A tiny blue square", "Zm9v"), ev_completed("resp-1"), ]); mount_sse_once(&server, first_response).await; @@ -422,7 +422,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { assert_eq!(completed.0.id, call_id); assert!(completed.1 > 0); assert_eq!(end.call_id, call_id); - assert_eq!(end.status, "completed"); + assert_eq!(end.status, "generating"); assert_eq!(end.revised_prompt, Some("A tiny blue square".to_string())); assert_eq!(end.result, "Zm9v"); assert_eq!( diff --git a/codex-rs/core/tests/suite/mcp__client_tool_calls.rs b/codex-rs/core/tests/suite/mcp__client_tool_calls.rs index 2be014b3f52f..2b4e09bd1c45 100644 --- a/codex-rs/core/tests/suite/mcp__client_tool_calls.rs +++ b/codex-rs/core/tests/suite/mcp__client_tool_calls.rs @@ -24,7 +24,6 @@ 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_models_manager::manager::RefreshStrategy; @@ -50,6 +49,7 @@ use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_wine_exec; use core_test_support::stdio_server_bin; @@ -339,13 +339,23 @@ async fn call_cwd_tool( fixture: &TestCodex, server_name: &str, call_id: &str, +) -> anyhow::Result { + call_structured_tool(server, fixture, server_name, "cwd", call_id).await +} + +async fn call_structured_tool( + server: &MockServer, + fixture: &TestCodex, + server_name: &str, + tool_name: &str, + call_id: &str, ) -> anyhow::Result { let namespace = format!("mcp__{server_name}"); mount_sse_once( server, responses::sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call_with_namespace(call_id, &namespace, "cwd", r#"{}"#), + responses::ev_function_call_with_namespace(call_id, &namespace, tool_name, r#"{}"#), responses::ev_completed("resp-1"), ]), ) @@ -353,7 +363,7 @@ async fn call_cwd_tool( mount_sse_once( server, responses::sse(vec![ - responses::ev_assistant_message("msg-1", "rmcp cwd tool completed successfully."), + responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."), responses::ev_completed("resp-2"), ]), ) @@ -361,7 +371,7 @@ async fn call_cwd_tool( fixture .codex - .submit(read_only_user_turn(fixture, "call the rmcp cwd tool")) + .submit(read_only_user_turn(fixture, "call the requested rmcp tool")) .await?; wait_for_event(&fixture.codex, |ev| { @@ -378,7 +388,7 @@ async fn call_cwd_tool( let structured_content = end .result .as_ref() - .expect("rmcp cwd tool should return success") + .expect("rmcp tool should return success") .structured_content .as_ref() .expect("structured content") @@ -388,6 +398,106 @@ async fn call_cwd_tool( Ok(structured_content) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_is_advertised_to_mcp_servers() -> anyhow::Result<()> { + assert_openai_form_capability_advertisement(/*expected*/ true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result<()> { + assert_openai_form_capability_advertisement(/*expected*/ false).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<()> { + skip_if_wine_exec!( + Ok(()), + "requires a Windows test_stdio_server in the Wine-exec environment" + ); + + let server = start_mock_server().await; + let server_name = "capabilities"; + let command = stdio_server_bin()?; + let fixture = test_codex() + .with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(command, /*env*/ None, Vec::new()), + TestMcpServerOptions::default(), + ); + }) + .build(&server) + .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + + let unsupported = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities-unsupported", + ) + .await?; + assert_eq!( + unsupported, + json!({ "supportsOpenaiFormElicitation": false }) + ); + + fixture + .codex + .set_openai_form_elicitation_support(/*supported*/ true) + .await?; + let supported = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities-supported", + ) + .await?; + assert_eq!(supported, json!({ "supportsOpenaiFormElicitation": true })); + Ok(()) +} + +async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> { + skip_if_wine_exec!( + Ok(()), + "requires a Windows test_stdio_server in the Wine-exec environment" + ); + + let server = start_mock_server().await; + let server_name = "capabilities"; + let command = stdio_server_bin()?; + let mut builder = test_codex().with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(command, /*env*/ None, Vec::new()), + TestMcpServerOptions::default(), + ); + }); + if expected { + builder = builder.with_openai_form_elicitation(); + } + let fixture = builder.build(&server).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + + let structured = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities", + ) + .await?; + assert_eq!( + structured, + json!({ "supportsOpenaiFormElicitation": expected }) + ); + Ok(()) +} + fn assert_cwd_tool_output(structured: &Value, expected_cwd: &Path) { let actual_cwd = structured .get("cwd") @@ -815,16 +925,11 @@ 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"); - let (sandbox_policy, _) = - turn_permission_fields(PermissionProfile::read_only(), fixture.config.cwd.as_path()); - let expected_sandbox_policy = serde_json::to_value(&sandbox_policy)?; - assert_eq!( - sandbox_meta.get("sandboxPolicy"), - Some(&expected_sandbox_policy) - ); + assert_eq!(sandbox_meta.get("sandboxPolicy"), None); + let expected_sandbox_cwd = PathUri::from_abs_path(&fixture.config.cwd).to_string(); assert_eq!( sandbox_meta.get("sandboxCwd").and_then(Value::as_str), - fixture.config.cwd.as_path().to_str() + Some(expected_sandbox_cwd.as_str()) ); assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false))); @@ -1231,7 +1336,9 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { tool: "image".to_string(), arguments: Some(json!({})), }, + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, }, ); @@ -1347,10 +1454,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/mcp__openai_file.rs b/codex-rs/core/tests/suite/mcp__openai_file.rs index 1f7d26be5bd1..edc14cead087 100644 --- a/codex-rs/core/tests/suite/mcp__openai_file.rs +++ b/codex-rs/core/tests/suite/mcp__openai_file.rs @@ -7,6 +7,7 @@ use anyhow::Context; use anyhow::Result; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_utils_path_uri::PathUri; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::CALENDAR_EXTRACT_TEXT_TOOL_NAME; use core_test_support::apps_test_server::DIRECT_CALENDAR_EXTRACT_TEXT_TOOL as DOCUMENT_EXTRACT_HOOK_MATCHER; @@ -16,6 +17,7 @@ use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE as DOCUMENT_E use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::recorded_apps_tool_call_by_name; use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::responses::ResponseMock; 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; @@ -23,16 +25,20 @@ 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::test_codex::TestCodex; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use wiremock::Mock; +use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::body_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; +const STREAMED_FILE_SIZE: usize = 13 * 1024 * 1024; + fn write_post_tool_use_hook(home: &Path) -> Result<()> { let script_path = home.join("post_tool_use_hook.py"); let log_path = home.join("post_tool_use_hook_log.jsonl"); @@ -82,17 +88,24 @@ fn read_post_tool_use_hook_inputs(home: &Path) -> Result> { .collect() } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Result<()> { - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; +fn uploaded_file(server: &MockServer, file_size_bytes: u64) -> Value { + json!({ + "download_url": format!("{}/download/file_123", server.uri()), + "file_id": "file_123", + "mime_type": "text/plain", + "file_name": "report.txt", + "uri": "sediment://file_123", + "file_size_bytes": file_size_bytes, + }) +} +async fn mount_file_upload_mocks(server: &MockServer, file_size_bytes: u64) { Mock::given(method("POST")) .and(path("/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(json!({ "file_name": "report.txt", - "file_size": 11, + "file_size": file_size_bytes, "use_case": "codex", }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -100,14 +113,14 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res "upload_url": format!("{}/upload/file_123", server.uri()), }))) .expect(1) - .mount(&server) + .mount(server) .await; Mock::given(method("PUT")) .and(path("/upload/file_123")) - .and(header("content-length", "11")) + .and(header("content-length", file_size_bytes.to_string())) .respond_with(ResponseTemplate::new(200)) .expect(1) - .mount(&server) + .mount(server) .await; Mock::given(method("POST")) .and(path("/files/file_123/uploaded")) @@ -116,20 +129,21 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res "download_url": format!("{}/download/file_123", server.uri()), "file_name": "report.txt", "mime_type": "text/plain", - "file_size_bytes": 11, + "file_size_bytes": file_size_bytes, }))) .expect(1) - .mount(&server) + .mount(server) .await; +} - let call_id = "extract-call-1"; +async fn run_extract_turn(test: &TestCodex, server: &MockServer) -> Result { let mock = mount_sse_sequence( - &server, + server, vec![ sse(vec![ ev_response_created("resp-1"), ev_function_call_with_namespace( - call_id, + "extract-call-1", DOCUMENT_EXTRACT_NAMESPACE, DOCUMENT_EXTRACT_TOOL, &json!({"file": "report.txt"}).to_string(), @@ -145,17 +159,6 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res ) .await; - let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) - .with_pre_build_hook(move |home| { - write_post_tool_use_hook(home) - .expect("failed to write apps file post tool use hook fixture"); - }) - .with_config(move |config| { - trust_discovered_hooks(config); - }); - let test = builder.build(&server).await?; - tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?; - test.submit_turn_with_approval_and_permission_profile( "Extract the report text with the app tool.", AskForApproval::Never, @@ -163,6 +166,29 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res ) .await?; + Ok(mock) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_apps_file_params_upload_environment_files_before_mcp_tool_call() -> Result<()> { + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_file_upload_mocks(&server, STREAMED_FILE_SIZE as u64).await; + + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) + .with_workspace_setup(|cwd, fs| async move { + let report_path = PathUri::from_abs_path(&cwd.join("report.txt")); + fs.write_file( + &report_path, + vec![b'x'; STREAMED_FILE_SIZE], + /*sandbox*/ None, + ) + .await?; + Ok(()) + }); + let test = builder.build_with_remote_env(&server).await?; + let mock = run_extract_turn(&test, &server).await?; + let requests = mock.requests(); let body = requests[0].body_json(); let missing_tool_message = format!( @@ -184,14 +210,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res assert_eq!( apps_tool_call.pointer("/params/arguments/file"), - Some(&json!({ - "download_url": format!("{}/download/file_123", server.uri()), - "file_id": "file_123", - "mime_type": "text/plain", - "file_name": "report.txt", - "uri": "sediment://file_123", - "file_size_bytes": 11, - })) + Some(&uploaded_file(&server, STREAMED_FILE_SIZE as u64)) ); assert_eq!( apps_tool_call.pointer("/params/_meta/_codex_apps"), @@ -203,18 +222,34 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res })) ); + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_apps_file_params_pass_uploaded_file_to_post_tool_use_hook() -> Result<()> { + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_file_upload_mocks(&server, /*file_size_bytes*/ 11).await; + + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) + .with_pre_build_hook(move |home| { + if let Err(error) = write_post_tool_use_hook(home) { + panic!("failed to write apps file post tool use hook fixture: {error}"); + } + }) + .with_config(move |config| { + trust_discovered_hooks(config); + }); + let test = builder.build(&server).await?; + tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?; + let _responses = run_extract_turn(&test, &server).await?; + let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?; assert_eq!(hook_inputs.len(), 1); assert_eq!( hook_inputs[0]["tool_input"]["file"], - json!({ - "download_url": format!("{}/download/file_123", server.uri()), - "file_id": "file_123", - "mime_type": "text/plain", - "file_name": "report.txt", - "uri": "sediment://file_123", - "file_size_bytes": 11, - }) + uploaded_file(&server, /*file_size_bytes*/ 11) ); server.verify().await; diff --git a/codex-rs/core/tests/suite/mcp__turn_metadata.rs b/codex-rs/core/tests/suite/mcp__turn_metadata.rs index 7fe0a95f4498..0d403180d7c4 100644 --- a/codex-rs/core/tests/suite/mcp__turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp__turn_metadata.rs @@ -60,7 +60,7 @@ default_tools_approval_mode = "{approval_mode}" .with_user_config(&user_config_path, user_config); } -fn set_calendar_approval_mode_and_default_reviewer( +fn set_default_app_approval_mode_and_reviewer( config: &mut Config, approval_mode: AppToolApproval, default_approvals_reviewer: ApprovalsReviewer, @@ -75,8 +75,6 @@ fn set_calendar_approval_mode_and_default_reviewer( r#" [apps._default] approvals_reviewer = "{default_approvals_reviewer}" - -[apps.calendar] default_tools_approval_mode = "{approval_mode}" "# )) @@ -167,7 +165,7 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> R .features .enable(Feature::ToolCallMcpElicitation) .expect("test config should allow feature update"); - set_calendar_approval_mode_and_default_reviewer( + set_default_app_approval_mode_and_reviewer( config, AppToolApproval::Prompt, ApprovalsReviewer::User, @@ -231,7 +229,8 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> R } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apps_default_auto_review_routes_actual_mcp_approval_to_guardian() -> Result<()> { +async fn apps_default_prompt_with_auto_review_routes_actual_mcp_approval_to_guardian() -> Result<()> +{ skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -285,7 +284,7 @@ async fn apps_default_auto_review_routes_actual_mcp_approval_to_guardian() -> Re .features .enable(Feature::ToolCallMcpElicitation) .expect("test config should allow feature update"); - set_calendar_approval_mode_and_default_reviewer( + set_default_app_approval_mode_and_reviewer( config, AppToolApproval::Prompt, ApprovalsReviewer::AutoReview, diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs new file mode 100644 index 000000000000..44f2f87c3e68 --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use codex_features::Feature; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::namespace_child_tool; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use serde_json::Value; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let response = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(move |config| { + config + .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()]; + }); + let test = builder.build(&server).await?; + test.submit_turn("inspect directly exposed MCP tools") + .await?; + let body = response.single_request().body_json(); + let tools = body + .get("tools") + .and_then(Value::as_array) + .expect("request should contain tools"); + + assert!( + namespace_child_tool( + &body, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + ) + .is_some(), + "configured MCP namespace should remain top-level: {body}" + ); + assert!( + !tools.iter().any(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(Value::as_str) + == Some("tool_search") + }), + "configured MCP namespace should not be deferred: {body}" + ); + let exec_description = tools.iter().find_map(|tool| { + (tool.get("name").and_then(Value::as_str) == Some("exec")) + .then(|| tool.get("description").and_then(Value::as_str)) + .flatten() + }); + assert!( + exec_description.is_some_and(|description| { + !description.contains("mcp__codex_apps__calendar_create_event(args:") + }), + "direct-model-only MCP namespace should not be available through exec: {body}" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 31dbc77bca6b..b3e39276b65f 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -50,6 +50,7 @@ mod compact; mod compact_remote; mod compact_remote_parity; mod compact_resume_fork; +mod current_time_reminder; mod deprecation_notice; mod exec; mod exec_policy; @@ -58,7 +59,6 @@ mod extension_sandbox; mod fork_thread; #[cfg(not(target_os = "windows"))] mod guardian_review; -mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; mod image_rollout; @@ -72,6 +72,7 @@ mod mcp_client_tool_calls; mod mcp_hooks; #[path = "mcp__openai_file.rs"] mod mcp_openai_file; +mod mcp_tool_exposure; #[path = "mcp__turn_metadata.rs"] mod mcp_turn_metadata; mod model_overrides; @@ -81,6 +82,8 @@ mod model_switching; mod model_visible_layout; mod models_cache_ttl; mod models_etag_responses; +mod multi_agent_mode; +mod network_approval; mod otel; mod override_updates; mod pending_input; @@ -108,7 +111,9 @@ mod responses_lite; mod resume; mod resume_warning; mod review; +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_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 22363eabea3a..4688434a4d78 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 @@ -649,8 +649,8 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { ); assert_eq!( image_generation_calls[0]["id"].as_str(), - Some("ig_123"), - "expected the original image generation call id to be preserved" + None, + "expected the image generation call id to be omitted" ); assert_eq!( image_generation_calls[0]["result"].as_str(), @@ -766,8 +766,8 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima ); assert_eq!( image_generation_calls[0]["id"].as_str(), - Some("ig_123"), - "second request should preserve the original generated image call id" + None, + "second request should omit the generated image call id" ); assert_eq!( image_generation_calls[0]["result"].as_str(), diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs new file mode 100644 index 000000000000..21ef31c4923c --- /dev/null +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -0,0 +1,369 @@ +use anyhow::Result; +use codex_features::Feature; +use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +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_sse_once; +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::test_codex::test_codex; +use core_test_support::wait_for_event; +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> { + input + .iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("developer")) + .filter_map(|item| item.get("content")?.as_array()) + .flatten() + .filter_map(|content| content.get("text")?.as_str()) + .collect() +} + +fn count_containing(texts: &[&str], target: &str) -> usize { + texts.iter().filter(|text| text.contains(target)).count() +} + +async fn submit_turn( + codex: &codex_core::CodexThread, + prompt: &str, + mode: Option, +) -> Result<()> { + 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: ThreadSettingsOverrides { + multi_agent_mode: mode, + ..Default::default() + }, + }) + .await?; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=5) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .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, + 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 + .iter() + .map(core_test_support::responses::ResponsesRequest::input) + .collect::>(); + 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!( + ( + count_containing(&first, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&first, NO_SPAWN_TEXT), + count_containing(&first, PROACTIVE_TEXT), + ), + (1, 1, 0) + ); + assert_eq!( + ( + count_containing(&second, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&second, NO_SPAWN_TEXT), + count_containing(&second, PROACTIVE_TEXT), + ), + (2, 1, 1) + ); + assert_eq!( + ( + count_containing(&third, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&third, NO_SPAWN_TEXT), + count_containing(&third, PROACTIVE_TEXT), + ), + (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_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; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| { + config + .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", 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, PROACTIVE_TEXT), + ), + (1, 1) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_compares_against_previous_effective_multi_agent_mode() -> 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::Proactive), + ) + .await?; + 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::Proactive + ); + + let requests = responses.requests(); + let resumed_input = requests[1].input(); + let texts = developer_texts(&resumed_input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (1, 0, 1) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .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, + MultiAgentMode::Proactive + ); + let input = responses.single_request().input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, PROACTIVE_TEXT), + ), + (0, 0) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs new file mode 100644 index 000000000000..14db16c60a01 --- /dev/null +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -0,0 +1,339 @@ +use anyhow::Context; +use anyhow::Result; +use codex_config::types::ApprovalsReviewer; +use codex_core::config::Constrained; +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_protocol::approvals::NetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; +use codex_protocol::user_input::UserInput; +use codex_utils_path_uri::PathUri; +use core_test_support::PathBufExt; +use core_test_support::PathExt; +use core_test_support::get_remote_test_env; +use core_test_support::managed_network_requirements_loader; +use core_test_support::responses::ResponseMock; +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_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::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::skip_if_windows; +use core_test_support::skip_if_wine_exec; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local; +use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use tempfile::TempDir; + +const NETWORK_TEST_HOST: &str = "codex-network-test.invalid"; +const NETWORK_TEST_TARGET: &str = "http://codex-network-test.invalid:80"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approved_network_host_for_one_environment_still_prompts_in_another() -> Result<()> { + skip_if_wine_exec!(Ok(()), "uses the POSIX/Python network fixture"); + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let test = managed_network_unified_exec_test(&server).await?; + let local_cwd = TempDir::new()?; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-network-approval-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + test.fs() + .create_directory( + &remote_cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + let environments = vec![ + local(local_cwd.path().abs()), + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&remote_cwd), + }, + ]; + + mount_exec_network_turn( + &server, + "resp-network-local", + "exec-network-local", + network_fetch_args(LOCAL_ENVIRONMENT_ID), + ) + .await?; + submit_managed_network_turn( + &test, + "fetch from the local environment", + environments.clone(), + ) + .await?; + let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?; + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::ApprovedForSession, + }) + .await?; + wait_for_turn_complete(&test).await; + + mount_exec_network_turn( + &server, + "resp-network-remote", + "exec-network-remote", + network_fetch_args(REMOTE_ENVIRONMENT_ID), + ) + .await?; + submit_managed_network_turn( + &test, + "fetch from the remote environment", + environments.clone(), + ) + .await?; + let approval = expect_network_approval(&test, REMOTE_ENVIRONMENT_ID).await?; + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Denied, + }) + .await?; + wait_for_turn_complete(&test).await; + + test.fs() + .remove( + &remote_cwd_uri, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + +async fn managed_network_unified_exec_test(server: &wiremock::MockServer) -> Result { + let home = Arc::new(TempDir::new()?); + fs::write( + home.path().join("config.toml"), + r#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] +enabled = true +mode = "limited" +allow_local_binding = true +"#, + )?; + let approval_policy = AskForApproval::OnFailure; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + let permission_profile_for_config = permission_profile.clone(); + let mut builder = test_codex() + .with_home(home) + .with_cloud_config_bundle(managed_network_requirements_loader()) + .with_config(move |config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); + }); + let test = builder.build_with_remote_and_local_env(server).await?; + assert!( + test.config.managed_network_requirements_enabled(), + "expected managed network requirements to be enabled" + ); + assert!( + test.config.permissions.network.is_some(), + "expected managed network proxy config to be present" + ); + test.session_configured + .network_proxy + .as_ref() + .expect("expected runtime managed network proxy addresses"); + + Ok(test) +} + +async fn mount_exec_network_turn( + server: &wiremock::MockServer, + response_prefix: &str, + call_id: &str, + args: Value, +) -> Result { + let responses = vec![ + sse(vec![ + ev_response_created(&format!("{response_prefix}-1")), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed(&format!("{response_prefix}-1")), + ]), + sse(vec![ + ev_response_created(&format!("{response_prefix}-2")), + ev_assistant_message(&format!("{response_prefix}-msg"), "done"), + ev_completed(&format!("{response_prefix}-2")), + ]), + ]; + Ok(mount_sse_sequence(server, responses).await) +} + +fn network_fetch_args(environment_id: &str) -> Value { + json!({ + "shell": "/bin/sh", + "cmd": format!("python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\""), + "login": false, + "yield_time_ms": 1_000, + "environment_id": environment_id, + }) +} + +async fn submit_managed_network_turn( + test: &TestCodex, + prompt: &str, + environments: Vec, +) -> Result<()> { + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); + let turn_environment_selections = + TurnEnvironmentSelections::new(test.config.cwd.clone(), environments); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + environments: Some(turn_environment_selections), + approval_policy: Some(AskForApproval::OnFailure), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + Ok(()) +} + +async fn expect_network_approval( + test: &TestCodex, + expected_environment_id: &str, +) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let remaining = deadline + .checked_duration_since(std::time::Instant::now()) + .context("timed out waiting for network approval request")?; + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + remaining, + ) + .await; + match event { + EventMsg::ExecApprovalRequest(approval) => { + assert_eq!( + approval.command, + vec![ + "network-access".to_string(), + NETWORK_TEST_TARGET.to_string() + ] + ); + assert_eq!( + approval.network_approval_context, + Some(NetworkApprovalContext { + host: NETWORK_TEST_HOST.to_string(), + protocol: NetworkApprovalProtocol::Http, + }) + ); + assert_eq!( + approval.environment_id.as_deref(), + Some(expected_environment_id) + ); + Ok(approval) + } + EventMsg::TurnComplete(_) => { + panic!("expected network approval request before completion"); + } + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_turn_complete(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; +} diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 530227415339..bbab96181734 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -59,6 +59,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, @@ -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__request_install.rs b/codex-rs/core/tests/suite/plugins__request_install.rs index b35ecbc221e4..f307fce05af1 100644 --- a/codex-rs/core/tests/suite/plugins__request_install.rs +++ b/codex-rs/core/tests/suite/plugins__request_install.rs @@ -2,29 +2,58 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::ToolSuggestDiscoverableType; use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +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::apps_test_server::AppsTestServer; 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_response_created; 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::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockGuard; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; +const REMOTE_CALENDAR_PLUGIN_CONFIG_ID: &str = "calendar@openai-curated-remote"; +const REMOTE_CALENDAR_PLUGIN_ID: &str = "plugin_calendar"; +const CALENDAR_CONNECTOR_ID: &str = "calendar"; +const CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +const CALENDAR_CREATE_EVENT_TOOL: &str = "_create_event"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -43,36 +72,20 @@ fn tool_names(body: &Value) -> Vec { .unwrap_or_default() } -fn function_tool_description(body: &Value, name: &str) -> Option { - body.get("tools") - .and_then(Value::as_array) - .and_then(|tools| { - tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some(name) { - tool.get("description") - .and_then(Value::as_str) - .map(str::to_string) - } else { - None - } - }) - }) -} - fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) { - config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::Plugins) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::ToolSuggest) - .expect("test config should allow feature update"); - let mut model_catalog = bundled_models_response().expect("bundled models.json should parse"); + for feature in [ + Feature::Apps, + Feature::Plugins, + Feature::RemotePlugin, + Feature::ToolSuggest, + ] { + config + .features + .enable(feature) + .expect("test config should allow feature update"); + } + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); let model = model_catalog .models .iter_mut() @@ -88,30 +101,202 @@ fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) config.model_catalog = Some(model_catalog); } +async fn mount_recommendations(server: &wiremock::MockServer, response: ResponseTemplate) { + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with(response) + .mount(server) + .await; +} + +fn assert_legacy_tools(body: &Value) { + let tools = tool_names(body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!( + tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), + "legacy mode should expose {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + ); + assert!( + tools + .iter() + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME), + "legacy mode should expose {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" + ); +} + +async fn build_test( + server: &wiremock::MockServer, + apps_server: &AppsTestServer, +) -> Result { + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config({ + let apps_base_url = apps_server.chatgpt_base_url.clone(); + move |config| configure_apps_without_search_tool(config, apps_base_url.as_str()) + }); + builder.build(server).await +} + +async fn start_install_turn(test: &TestCodex, prompt: &str) -> Result { + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + 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: ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + Ok(wait_for_event_match(&test.codex, |event| match event { + EventMsg::ElicitationRequest(request) => Some(request.clone()), + _ => None, + }) + .await) +} + +async fn resolve_install_elicitation( + test: &TestCodex, + elicitation: ElicitationRequestEvent, + decision: ElicitationAction, +) -> Result<()> { + test.codex + .submit(Op::ResolveElicitation { + server_name: elicitation.server_name, + request_id: elicitation.id, + decision, + content: None, + meta: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + Ok(()) +} + +async fn mount_remote_calendar_recommendation(server: &wiremock::MockServer) { + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": REMOTE_CALENDAR_PLUGIN_ID, + "name": "calendar", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": { + "display_name": "Calendar", + "app_ids": [CALENDAR_CONNECTOR_ID] + } + }] + }))) + .expect(1) + .mount(server) + .await; +} + +fn remote_installed_plugins_response(plugins: Vec) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(json!({ + "plugins": plugins, + "pagination": { + "next_page_token": null + } + })) +} + +async fn mount_empty_remote_installed_plugins(server: &wiremock::MockServer) -> MockGuard { + Mock::given(method("GET")) + .and(path("/ps/plugins/installed")) + .respond_with(remote_installed_plugins_response(Vec::new())) + .mount_as_scoped(server) + .await +} + +async fn mount_remote_calendar_installed_plugins(server: &wiremock::MockServer) { + Mock::given(method("GET")) + .and(path("/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .respond_with(remote_installed_plugins_response(vec![json!({ + "id": REMOTE_CALENDAR_PLUGIN_ID, + "name": "calendar", + "scope": "GLOBAL", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Calendar", + "description": "Manage calendar events.", + "interface": {} + }, + "enabled": true + })])) + .with_priority(1) + .mount(server) + .await; + for scope in ["WORKSPACE", "USER"] { + Mock::given(method("GET")) + .and(path("/ps/plugins/installed")) + .and(query_param("scope", scope)) + .respond_with(remote_installed_plugins_response(Vec::new())) + .with_priority(1) + .mount(server) + .await; + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn request_plugin_install_is_available_without_search_tool_after_discovery_attempts() --> Result<()> { +async fn explicit_false_preserves_legacy_workflow() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_once( + mount_recommendations( &server, - sse(vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-1"), - ]), + ResponseTemplate::new(200).set_body_json(json!({"enabled": false, "plugins": []})), ) .await; - - let mut builder = test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - configure_apps_without_search_tool(config, apps_server.chatgpt_base_url.as_str()) - }); - let test = builder.build(&server).await?; - + let call_id = "list-installable-tools"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME, "{}"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; test.submit_turn_with_approval_and_permission_profile( "list tools", AskForApproval::Never, @@ -119,46 +304,345 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery ) .await?; - let body = mock.single_request().body_json(); + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let request = &requests[0]; + assert!( + !request + .message_input_texts("user") + .join("\n") + .contains("") + ); + assert_legacy_tools(&request.body_json()); + let output = requests[1] + .function_call_output_text(call_id) + .expect("list tool output"); + let output: Value = serde_json::from_str(&output)?; + assert!(output["tools"].as_array().is_some_and(|tools| { + tools + .iter() + .any(|tool| tool["id"] == DISCOVERABLE_GMAIL_ID && tool["tool_type"] == "connector") + })); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_mode_injects_candidates_hides_list_and_rejects_invented_ids() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_google_calendar", + "name": "google-calendar", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "Google Calendar"} + }, + { + "id": "plugin_github", + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "GitHub"} + } + ] + })), + ) + .await; + let call_id = "invented-plugin"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "plugin_id": "invented@openai-curated-remote", + "suggest_reason": "Try this" + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + + test.submit_turn("suggest a plugin").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let contextual_user_message = requests[0].message_input_texts("user").join("\n"); + assert!(contextual_user_message.contains("")); + assert!(contextual_user_message.contains("github@openai-curated-remote")); + assert!(contextual_user_message.contains("google-calendar@openai-curated-remote")); + let body = requests[0].body_json(); let tools = tool_names(&body); assert!( - !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), - "tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}" + !tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) ); assert!( tools .iter() - .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), - "tools list should include {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME) ); + let output = requests[1] + .function_call_output_text(call_id) + .expect("request tool output"); + assert!(output.contains(" list")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metadata() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + run_remote_plugin_install_metadata_case().await +} + +async fn run_remote_plugin_install_metadata_case() -> Result<()> { + const REMOTE_PLUGIN_ID: &str = "plugin_connector_github"; + const APP_CONNECTOR_ID: &str = "connector_github"; + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": REMOTE_PLUGIN_ID, + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": { + "display_name": "GitHub", + "app_ids": [APP_CONNECTOR_ID] + } + }] + })), + ) + .await; + let call_id = "install-github"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "plugin_id": "github@openai-curated-remote", + "suggest_reason": "Use GitHub for this request" + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + let elicitation = start_install_turn(&test, "use GitHub").await?; + let ElicitationRequest::Form { + meta: Some(meta), .. + } = &elicitation.request + else { + panic!("expected form elicitation metadata"); + }; + assert_eq!(meta["remote_plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(meta["app_connector_ids"], json!([APP_CONNECTOR_ID])); + + resolve_install_elicitation(&test, elicitation, ElicitationAction::Decline).await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + for request in requests { + let body = request.body_json().to_string(); + assert!(!body.contains(REMOTE_PLUGIN_ID)); + assert!(!body.contains(APP_CONNECTOR_ID)); + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum RefreshedAppsTools { + Available, + Missing, +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_plugin_install_refreshes_plugin_and_apps_tool_caches() -> Result<()> { + skip_if_no_network!(Ok(())); + + run_remote_plugin_install_refresh_case(RefreshedAppsTools::Available).await?; + run_remote_plugin_install_refresh_case(RefreshedAppsTools::Missing).await +} + +async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTools) -> Result<()> { + let server = start_mock_server().await; + let apps_server = match refreshed_tools { + RefreshedAppsTools::Available => { + AppsTestServer::mount_with_tools_available_after_initial_list(&server).await? + } + RefreshedAppsTools::Missing => AppsTestServer::mount_without_tools(&server).await?, + }; + mount_remote_calendar_recommendation(&server).await; + let initial_remote_installed_plugins = mount_empty_remote_installed_plugins(&server).await; + + let install_call_id = "install-calendar"; + let suggest_reason = "Use Calendar for this request"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + install_call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "plugin_id": REMOTE_CALENDAR_PLUGIN_CONFIG_ID, + "suggest_reason": suggest_reason + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + + let elicitation = start_install_turn(&test, "use Calendar").await?; + mount_remote_calendar_installed_plugins(&server).await; + drop(initial_remote_installed_plugins); + resolve_install_elicitation(&test, elicitation, ElicitationAction::Accept).await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); assert!( - tools + requests[0] + .tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL) + .is_none(), + "calendar tool should be absent before the remote install" + ); + let completed = matches!(refreshed_tools, RefreshedAppsTools::Available); + assert_eq!( + serde_json::from_str::( + &requests[1] + .function_call_output_text(install_call_id) + .expect("install tool output") + )?, + json!({ + "completed": completed, + "user_confirmed": true, + "tool_type": "plugin", + "action_type": "install", + "tool_id": REMOTE_CALENDAR_PLUGIN_CONFIG_ID, + "tool_name": "Calendar", + "suggest_reason": suggest_reason + }) + ); + assert_eq!( + requests[1] + .tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL) + .is_some(), + completed, + "the resumed router should reflect the refreshed Apps tools" + ); + assert!( + !tool_names(&requests[1].body_json()) .iter() .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME), - "tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" + "the refreshed installed-plugin cache should filter the cached recommendation" ); + Ok(()) +} - let list_description = - function_tool_description(&body, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) - .expect("description"); - assert!(list_description.contains( - "The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list." - )); - assert!(list_description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); - assert!(list_description.contains( - "When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed." - )); - - let description = - function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description"); - assert!(description.contains( - "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request." - )); - assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools.")); - assert!(!description.contains(DISCOVERABLE_GMAIL_ID)); - assert!(!description.contains("tool_search fails to find a good match")); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_mode_with_no_eligible_candidates_exposes_no_suggestion_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": "plugin_google_calendar", + "name": "google-calendar", + "release": {"display_name": "Google Calendar"} + }] + })), + ) + .await; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config({ + let apps_base_url = apps_server.chatgpt_base_url.clone(); + move |config| { + configure_apps_without_search_tool(config, apps_base_url.as_str()); + config.tool_suggest.disabled_tools = vec![ToolSuggestDisabledTool::plugin( + "google-calendar@openai-curated-remote", + )]; + } + }); + let test = builder.build(&server).await?; + + test.submit_turn("list tools").await?; + + let request = mock.single_request(); + assert!( + !request + .message_input_texts("user") + .join("\n") + .contains("") + ); + let tools = tool_names(&request.body_json()); + assert!( + !tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) + ); + assert!( + !tools + .iter() + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME) + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 5883a8564b14..0628f8ff980a 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -49,7 +49,7 @@ 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!(input.iter().any(|item| { diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 61fc38e8b154..1a95223bffb0 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -18,7 +18,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; @@ -50,6 +49,7 @@ use std::sync::Mutex; use std::time::Duration; use tokio::sync::oneshot; use tokio::time::timeout; +use uuid::Uuid; use wiremock::Match; use wiremock::Mock; use wiremock::Request as WiremockRequest; @@ -66,6 +66,7 @@ const MEMORY_PROMPT_PHRASE: &str = "You have access to a memory folder with guidance from prior runs."; const REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR: &str = "CODEX_REALTIME_CONVERSATION_TEST_SUBPROCESS"; +const SILENT_CONTEXT_PREFIX: &str = "[BACKEND] Silent Codex context. Do not speak, acknowledge, or summarize this item. Wait for an explicit speakable handoff or direct user request."; #[derive(Debug, Clone)] struct RealtimeCallRequestCapture { @@ -283,9 +284,10 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -428,9 +430,10 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -522,9 +525,10 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: Some("session-override-model".to_string()), output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -577,7 +581,10 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { // begin inference before the sideband WebSocket is ready. let request = capture.single_request(); assert_eq!(request.url.path(), "/v1/realtime/calls"); - assert_eq!(request.url.query(), None); + assert_eq!( + request.url.query(), + Some("intent=quicksilver&architecture=avas") + ); assert_eq!( request .headers @@ -666,7 +673,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> { +async fn conversation_webrtc_start_uses_avas_query() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -705,9 +712,10 @@ async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -764,6 +772,132 @@ async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let capture = RealtimeCallRequestCapture::new(); + Mock::given(method("POST")) + .and(path_regex(".*/realtime/calls$")) + .and(capture.clone()) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Location", "/v1/realtime/calls/calls/rtc_voice_default") + .set_body_string("v=answer\r\n"), + ) + .mount(&server) + .await; + let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_webrtc_voice", "instructions": "backend prompt" } + })]], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: false, + }]) + .await; + + let realtime_ws_base_url = realtime_server.uri().to_string(); + let mut builder = test_codex().with_config(move |config| { + config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string()); + config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url); + config.realtime.version = RealtimeWsVersion::V2; + config.realtime.voice = Some(RealtimeVoice::Cedar); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + client_managed_handoffs: false, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_prefix: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ConversationStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + version: None, + voice: None, + })) + .await?; + + let created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationSdp(created) => Some(Ok(created.clone())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .expect("conversation call create failed"); + assert_eq!(created.sdp, "v=answer\r\n"); + + let request = capture.single_request(); + let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; + assert!(body.contains(r#""type":"quicksilver""#)); + assert!(body.contains(r#""voice":"cove""#)); + assert!(!body.contains(r#""voice":"cedar""#)); + + let session_update = wait_for_websocket_request( + &realtime_server, + /*connection_index*/ 0, + /*request_index*/ 0, + ) + .await?; + assert_eq!( + session_update.body_json()["session"]["audio"]["output"]["voice"], + "cove" + ); + + test.codex.submit(Op::RealtimeConversationClose).await?; + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.realtime.version = RealtimeWsVersion::V2; + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + client_managed_handoffs: false, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_prefix: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ConversationStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + version: None, + voice: Some(RealtimeVoice::Cedar), + })) + .await?; + + let error = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error(message), + }) => Some(message.clone()), + _ => None, + }) + .await; + assert!(error.contains("realtime voice `cedar` is not supported for v1")); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> Result<()> { skip_if_no_network!(Ok(())); @@ -806,9 +940,10 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -899,9 +1034,10 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -989,9 +1125,10 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1081,9 +1218,10 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1153,9 +1291,10 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1249,9 +1388,10 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1299,9 +1439,10 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1397,9 +1538,10 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1426,9 +1568,10 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1526,9 +1669,10 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1594,9 +1738,10 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1670,9 +1815,10 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu ] { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1739,9 +1885,10 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1800,9 +1947,10 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1849,9 +1997,10 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1899,9 +2048,10 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1975,9 +2125,10 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2045,9 +2196,10 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2108,9 +2260,10 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2197,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, @@ -2206,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, }), ] }) @@ -2219,15 +2372,17 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge InitialHistory::Forked(history), auth_manager_from_auth(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await?; let codex = resumed_thread.thread; codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2336,9 +2491,10 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2399,9 +2555,10 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2483,9 +2640,10 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2583,9 +2741,10 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2685,9 +2844,10 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2763,6 +2923,10 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> skip_if_no_network!(Ok(())); let (gate_second_message_tx, gate_second_message_rx) = oneshot::channel(); + let mut commentary_message = responses::ev_assistant_message("msg-1", "assistant message 1"); + commentary_message["item"]["phase"] = json!("commentary"); + let mut final_message = responses::ev_assistant_message("msg-2", "assistant message 2"); + final_message["item"]["phase"] = json!("final_answer"); let first_chunks = vec![ StreamingSseChunk { gate: None, @@ -2770,17 +2934,11 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> }, StreamingSseChunk { gate: None, - body: sse_event(responses::ev_assistant_message( - "msg-1", - "assistant message 1", - )), + body: sse_event(commentary_message), }, StreamingSseChunk { gate: Some(gate_second_message_rx), - body: sse_event(responses::ev_assistant_message( - "msg-2", - "assistant message 2", - )), + body: sse_event(final_message), }, StreamingSseChunk { gate: None, @@ -2825,9 +2983,10 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: Some(SILENT_CONTEXT_PREFIX.to_string()), model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2872,7 +3031,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> ); assert_eq!( first_append.body_json()["output_text"].as_str(), - Some("\"Agent Final Message\":\n\nassistant message 1") + Some(format!("{SILENT_CONTEXT_PREFIX}\n\nassistant message 1").as_str()) ); let _ = wait_for_event_match(&test.codex, |msg| match msg { @@ -2898,7 +3057,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> ); assert_eq!( second_append.body_json()["output_text"].as_str(), - Some("\"Agent Final Message\":\n\nassistant message 2") + Some("assistant message 2") ); let completion = completions @@ -2980,9 +3139,10 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3019,6 +3179,14 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { }) .await; + let turn_id = loop { + let event = test.codex.next_event().await?; + if let EventMsg::TurnStarted(turn_started) = event.msg { + break turn_started.turn_id; + } + }; + Uuid::parse_str(&turn_id).context("realtime-routed turn ID should be a UUID")?; + wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) @@ -3085,9 +3253,10 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3191,9 +3360,10 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3295,9 +3465,10 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3421,9 +3592,10 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3577,9 +3749,10 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3722,9 +3895,10 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3878,9 +4052,10 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 311485c1121b..fa805fa446e7 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,15 +51,26 @@ 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; +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; @@ -74,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(), @@ -90,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 { @@ -228,6 +242,447 @@ async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { + const CALL_ID: &str = "remote-explicit-shell"; + + let (shell, command) = match core_test_support::test_environment() { + TestEnvironment::Docker { .. } => ( + "bash", + r#"case "$PWD" in /tmp/codex-core-test-cwd-*) ;; *) echo "unexpected cwd: $PWD" >&2; exit 1 ;; esac"#, + ), + TestEnvironment::WineExec => ( + "powershell", + r#"$cwd = (Get-Location).Path; if ($cwd -notlike 'C:\codex-core-test-cwd-*') { Write-Error "unexpected cwd: $cwd"; exit 1 }"#, + ), + TestEnvironment::Local => return Ok(()), + }; + + let server = start_mock_server().await; + let arguments = serde_json::to_string(&json!({ + "cmd": command, + "shell": shell, + "login": false, + "yield_time_ms": 10_000, + }))?; + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build_with_remote_env(&server).await?; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(CALL_ID, "exec_command", &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + test.submit_turn_with_environments( + "run the remote shell in the remote cwd", + Some(vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&test.config.cwd), + }]), + ) + .await?; + let request = response_mock + .last_request() + .context("model should receive the command output")?; + let (output, success) = request + .function_call_output_content_and_success(CALL_ID) + .context("remote shell tool result should be present")?; + assert_ne!(success, Some(false)); + assert!( + output.is_some_and(|output| output.contains("Process exited with code 0")), + "remote shell command should exit successfully", + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +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_path(&remote_cwd)?; + let target_uri = PathUri::from_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, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .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 = timeout(Duration::from_secs(5), builder.build(&server)) + .await + .context("thread startup should not wait for the remote environment")??; + + 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(()) +} + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("path should be absolute") } @@ -525,6 +980,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result cwd: PathUri::from_abs_path(&remote_cwd), }, ], + AskForApproval::OnRequest, ) .await?; @@ -806,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; @@ -825,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; @@ -849,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; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 1b04582847e1..afa8e2194d51 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -41,6 +41,7 @@ use serde_json::Value; use serde_json::json; use std::fs; use std::path::Path; +use test_case::test_case; fn absolute_path(path: &Path) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("absolute path") @@ -164,23 +165,6 @@ fn exec_command_event_with_missing_additional_permissions( Ok(ev_function_call(call_id, "exec_command", &args_str)) } -fn shell_event_with_raw_request_permissions( - call_id: &str, - command: &str, - workdir: Option<&str>, - additional_permissions: Value, -) -> Result { - let args = json!({ - "command": command, - "workdir": workdir, - "timeout_ms": 1_000_u64, - "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, - "additional_permissions": additional_permissions, - }); - let args_str = serde_json::to_string(&args)?; - Ok(ev_function_call(call_id, "shell_command", &args_str)) -} - async fn submit_turn( test: &TestCodex, prompt: &str, @@ -507,8 +491,18 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio Ok(()) } +#[derive(Clone, Copy, Debug)] +enum AdditionalPermissionsCommandTool { + ShellCommand, + ExecCommand, +} + +#[test_case(AdditionalPermissionsCommandTool::ShellCommand ; "shell_command")] +#[test_case(AdditionalPermissionsCommandTool::ExecCommand ; "exec_command")] #[tokio::test(flavor = "current_thread")] -async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> { +async fn relative_additional_permissions_resolve_against_tool_workdir( + command_tool: AdditionalPermissionsCommandTool, +) -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -542,6 +536,12 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let call_id = "request_permissions_relative_workdir"; let command = "touch relative-write.txt"; + let workdir = "nested"; + let additional_permissions = json!({ + "file_system": { + "write": ["."], + }, + }); let expected_permissions = PermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( /*read*/ None, @@ -549,16 +549,27 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul )), ..Default::default() }; - let event = shell_event_with_raw_request_permissions( - call_id, - command, - Some("nested"), - json!({ - "file_system": { - "write": ["."], - }, - }), - )?; + let (tool_name, arguments) = match command_tool { + AdditionalPermissionsCommandTool::ShellCommand => ( + "shell_command", + json!({ + "command": command, + "workdir": workdir, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }), + ), + AdditionalPermissionsCommandTool::ExecCommand => ( + "exec_command", + json!({ + "cmd": command, + "workdir": workdir, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }), + ), + }; + let event = ev_function_call(call_id, tool_name, &serde_json::to_string(&arguments)?); let _ = mount_sse_once( &server, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index e8434896eafb..f92a0e9a90ff 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -32,6 +32,7 @@ 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; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; @@ -173,9 +174,11 @@ async fn submit_turn( } async fn wait_for_completion(test: &TestCodex) { - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) + wait_for_event_with_timeout( + &test.codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + tokio::time::Duration::from_secs(/*secs*/ 60), + ) .await; } @@ -462,12 +465,16 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu assert!(guardian_request.body_contains_text(requested_file_name)); assert!(guardian_request.body_contains_text(patch_content)); } else { - let event = wait_for_event(&test.codex, |event| { - matches!( - event, - EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_) - ) - }) + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + tokio::time::Duration::from_secs(/*secs*/ 60), + ) .await; match event { EventMsg::TurnComplete(_) => {} diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index b137d5bc7303..db4faa675e15 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -55,7 +55,7 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { } #[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_strips_data_image_detail() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0ba392298468..073e6d8b49c9 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -27,7 +27,7 @@ fn resume_history( let turn_id = "resume-warning-seed-turn".to_string(); let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), - cwd: config.cwd.to_path_buf(), + cwd: config.cwd.clone(), workspace_roots: None, current_date: None, timezone: None, @@ -41,6 +41,7 @@ fn resume_history( personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: config.model_reasoning_effort.clone(), summary: config @@ -110,6 +111,7 @@ async fn emits_warning_when_resumed_model_differs() { initial_history, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume conversation"); diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index ad04e3b8d9ad..d54b48cab5bb 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -579,6 +579,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": ".", @@ -599,7 +600,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!({ @@ -619,7 +620,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/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs new file mode 100644 index 000000000000..feafdc51efe9 --- /dev/null +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -0,0 +1,439 @@ +use anyhow::Result; +use codex_core::config::RolloutBudgetConfig; +use codex_features::Feature; +use codex_model_provider_info::built_in_model_providers; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::user_input::UserInput; +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_response_created; +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::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use test_case::test_case; +use tokio::time::timeout; + +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 + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect() +} + +fn rollout_budget_message(remaining_tokens: i64) -> String { + format!( + "\nYou have {remaining_tokens} weighted tokens left in the shared session token budget.\n" + ) +} + +fn wire_request_contains(request: &wiremock::Request, text: &str) -> bool { + std::str::from_utf8(&request.body).is_ok_and(|body| body.contains(text)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adds_weighted_initial_and_threshold_reminders() -> 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"), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1", + "usage": { + "input_tokens": 60, + "input_tokens_details": { "cached_tokens": 40 }, + "output_tokens": 15, + "output_tokens_details": null, + "total_tokens": 75 + } + } + }), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.rollout_budget = Some(RolloutBudgetConfig { + sampling_token_weight: 2.0, + prefill_token_weight: 0.5, + ..rollout_budget() + }); + }) + .build(&server) + .await?; + + test.submit_turn("first turn").await?; + test.submit_turn("second turn").await?; + + let requests = responses.requests(); + assert_eq!( + rollout_budget_texts(&requests[0]), + vec![rollout_budget_message(/*remaining_tokens*/ 100)] + ); + assert_eq!( + rollout_budget_texts(&requests[1]), + vec![ + rollout_budget_message(/*remaining_tokens*/ 100), + rollout_budget_message(/*remaining_tokens*/ 60), + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { + skip_if_no_network!(Ok(())); + + const ROOT_PROMPT: &str = "spawn a budget worker"; + const CHILD_PROMPT: &str = "consume child budget"; + const FOLLOW_UP_PROMPT: &str = "report the shared budget"; + const SPAWN_CALL_ID: &str = "spawn-budget-worker"; + + let server = start_mock_server().await; + let spawn_args = json!({ + "message": CHILD_PROMPT, + "task_name": "budget_worker", + }) + .to_string(); + mount_sse_once_match( + &server, + |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_completed_with_tokens("root-1", /*total_tokens*/ 10), + ]), + ) + .await; + mount_sse_once_match( + &server, + |request: &wiremock::Request| wire_request_contains(request, "\"type\":\"agent_message\""), + sse(vec![ + ev_response_created("child-1"), + ev_completed_with_tokens("child-1", /*total_tokens*/ 30), + ]), + ) + .await; + mount_sse_once_match( + &server, + |request: &wiremock::Request| { + wire_request_contains(request, SPAWN_CALL_ID) + && !wire_request_contains(request, "\"type\":\"agent_message\"") + }, + sse(vec![ + ev_response_created("root-2"), + ev_completed_with_tokens("root-2", /*total_tokens*/ 10), + ]), + ) + .await; + let follow_up = mount_sse_once_match( + &server, + |request: &wiremock::Request| wire_request_contains(request, FOLLOW_UP_PROMPT), + sse(vec![ev_response_created("root-3"), ev_completed("root-3")]), + ) + .await; + + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow multi-agent tools"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow multi-agent v2"); + config.rollout_budget = Some(rollout_budget()); + }) + .build(&server) + .await?; + + let mut created_threads = test.thread_manager.subscribe_thread_created(); + test.submit_turn(ROOT_PROMPT).await?; + let child_thread_id = timeout(Duration::from_secs(10), created_threads.recv()).await??; + let child_thread = test.thread_manager.get_thread(child_thread_id).await?; + wait_for_event(child_thread.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn(FOLLOW_UP_PROMPT).await?; + + let request = follow_up.single_request(); + assert_eq!( + rollout_budget_texts(&request).last(), + Some(&rollout_budget_message(/*remaining_tokens*/ 50)) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("exhaust-budget"), + ev_completed_with_tokens("exhaust-budget", /*total_tokens*/ 30), + ]), + sse(vec![ + ev_response_created("already-exhausted"), + ev_completed_with_tokens("already-exhausted", /*total_tokens*/ 1), + ]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.rollout_budget = Some(RolloutBudgetConfig { + limit_tokens: 30, + reminder_at_remaining_tokens: vec![20, 10], + ..rollout_budget() + }); + }) + .build(&server) + .await?; + + for prompt in ["exhaust the budget", "try another 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 event = wait_for_event(&test.codex, |event| match event { + EventMsg::TurnAborted(_) => true, + EventMsg::TurnComplete(_) => { + panic!("exhausted budget completed the turn instead of aborting") + } + _ => false, + }) + .await; + let EventMsg::TurnAborted(abort) = event else { + unreachable!("event filter only accepts TurnAborted") + }; + assert_eq!(abort.reason, TurnAbortReason::Interrupted); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(false ; "local")] +#[test_case(true ; "remote_v2")] +async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: bool) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let compact_response = if remote_v2 { + sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "encrypted-summary", + } + }), + ev_completed_with_tokens("compact", /*total_tokens*/ 10), + ]) + } else { + sse(vec![ + ev_response_created("compact"), + ev_assistant_message("compact-summary", "compact summary"), + ev_completed_with_tokens("compact", /*total_tokens*/ 10), + ]) + }; + 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_at_remaining_tokens: vec![5], + ..rollout_budget() + }); + if remote_v2 { + config + .features + .enable(Feature::RemoteCompactionV2) + .expect("test config should allow remote compaction v2"); + } + }) + .build(&server) + .await?; + + test.codex.submit(Op::Compact).await?; + let event = wait_for_event(&test.codex, |event| match event { + EventMsg::TurnAborted(_) => true, + EventMsg::Error(error) => panic!("budget exhaustion emitted an error: {}", error.message), + EventMsg::TurnComplete(_) => { + panic!("budget-exhausting compaction completed instead of aborting") + } + _ => false, + }) + .await; + let EventMsg::TurnAborted(abort) = event else { + unreachable!("event filter only accepts TurnAborted") + }; + assert_eq!(abort.reason, TurnAbortReason::Interrupted); + assert_eq!(responses.requests().len(), 1, "compaction should not retry"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restates_the_current_remainder_after_compaction() -> 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_with_tokens("resp-1", /*total_tokens*/ 20), + ]), + sse(vec![ + ev_response_created("resp-compact"), + ev_assistant_message("msg-compact", "compact summary"), + ev_completed_with_tokens("resp-compact", /*total_tokens*/ 10), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + model_provider.name = "OpenAI-compatible test provider".to_string(); + model_provider.base_url = Some(format!("{}/v1", server.uri())); + model_provider.supports_websockets = false; + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + config.rollout_budget = Some(RolloutBudgetConfig { + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() + }); + }) + .build(&server) + .await?; + + test.submit_turn("first turn").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("second turn").await?; + + let requests = responses.requests(); + assert_eq!( + rollout_budget_texts(&requests[2]), + vec![rollout_budget_message(/*remaining_tokens*/ 70)], + "a new context window should restate the current remainder" + ); + let request_body = requests[2].body_json().to_string(); + let summary_position = request_body + .find("compact summary") + .expect("post-compaction request should contain the summary"); + let reminder_position = request_body + .find("You have 70 weighted tokens left in the shared session token budget.") + .expect("post-compaction request should contain the current remainder"); + assert!( + summary_position < reminder_position, + "the current remainder should follow the compaction summary" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restates_the_current_remainder_after_rollback() -> 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_with_tokens("resp-1", /*total_tokens*/ 30), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.rollout_budget = Some(RolloutBudgetConfig { + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() + }); + }) + .build(&server) + .await?; + + test.submit_turn("rolled-back turn").await?; + test.codex + .submit(Op::ThreadRollback { num_turns: 1 }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ThreadRolledBack(_)) + }) + .await; + test.submit_turn("turn after rollback").await?; + + let requests = responses.requests(); + assert_eq!( + rollout_budget_texts(&requests[1]), + vec![rollout_budget_message(/*remaining_tokens*/ 70)], + "rollback should rearm the current budget reminder without refunding usage" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 680301d9f962..1461c1af3568 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 000000000000..6c2df1b6da3a --- /dev/null +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -0,0 +1,63 @@ +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_sse_once; +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::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; + +#[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_sse_once(&server, sse(vec![created, ev_completed("resp-1")])).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()], + } + ); + 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 05100dc16153..472d74ebb9ac 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -25,6 +25,7 @@ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::LINK_ID; 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; @@ -610,10 +611,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - unreachable!("event guard guarantees McpToolCallEnd"); }; assert_eq!(end.call_id, "calendar-call-1"); + assert_eq!(end.connector_id.as_deref(), Some("calendar")); assert_eq!( end.mcp_app_resource_uri.as_deref(), Some(CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI) ); + assert_eq!(end.link_id.as_deref(), Some(LINK_ID)); assert_eq!( end.invocation, McpInvocation { 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 e3dddb45cac0..c0c905625fa4 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: 588 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 1.\nYou have 121600 tokens left in this context window.\n + [03] Thread id .\nFirst context window id: \nCurrent context window id: \nPrevious context window id: 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 eae82de9e108..36a51f80c3ca 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 b9927fd28dff..27a5cef59341 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -40,6 +40,7 @@ use serde_json::json; use std::fs; use std::path::Path; use std::time::Duration; +use test_case::test_case; use tokio::time::Instant; use tokio::time::sleep; use wiremock::MockServer; @@ -426,7 +427,7 @@ async fn setup_turn_one_with_custom_spawned_child( .codex .rollout_path() .ok_or_else(|| anyhow::anyhow!("expected parent rollout path"))?; - let deadline = Instant::now() + Duration::from_secs(6); + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 20); loop { let has_notification = tokio::fs::read_to_string(&rollout_path) .await @@ -750,9 +751,11 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; @@ -1081,26 +1084,40 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result .await? .pop() .expect("child request"); - let expected_agent_message = json!({ - "type": "agent_message", - "author": "/root", - "recipient": "/root/worker", - "content": [{ - "type": "encrypted_content", - "encrypted_content": encrypted_message, - }], - }); - let agent_messages = child_request.inputs_of_type("agent_message"); - assert!( - agent_messages.contains(&expected_agent_message), - "child request should include encrypted agent message: {agent_messages:#?}" + assert_eq!( + child_request.inputs_of_type("agent_message"), + vec![json!({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [ + { + "type": "input_text", + "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n", + }, + { + "type": "encrypted_content", + "encrypted_content": encrypted_message, + }, + ], + })] ); Ok(()) } +#[derive(Clone, Copy)] +enum CompletionScenario { + Completed, + TerminalError, +} + +#[test_case(CompletionScenario::Completed ; "completed")] +#[test_case(CompletionScenario::TerminalError ; "terminal_error")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()> { +async fn plaintext_multi_agent_v2_completion_sends_agent_message( + scenario: CompletionScenario, +) -> Result<()> { let server = start_mock_server().await; let spawn_args = serde_json::to_string(&json!({ "message": "opaque-encrypted-message", @@ -1116,21 +1133,24 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()> ]), ) .await; - let child_request = mount_response_once_match( - &server, - |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), - sse_response(sse(vec![ + let child_events = match scenario { + CompletionScenario::Completed => vec![ ev_response_created("resp-child-1"), ev_assistant_message("msg-child-1", "child done"), ev_completed("resp-child-1"), - ])) - .set_delay(Duration::from_secs(1)), + ], + CompletionScenario::TerminalError => vec![ev_response_created("resp-child-1")], + }; + let child_request = mount_response_once_match( + &server, + |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + sse_response(sse(child_events)).set_delay(Duration::from_secs(1)), ) .await; mount_sse_once_match( &server, |req: &wiremock::Request| { - body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "") + body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "Message Type: FINAL_ANSWER") }, sse(vec![ ev_response_created("resp-parent-2"), @@ -1139,14 +1159,26 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()> ]), ) .await; - let notification = "\n{\"agent_path\":\"/root/worker\",\"status\":{\"completed\":\"child done\"}}\n"; + let error = "stream disconnected before completion: stream closed before response.completed"; + let (payload, expected_text) = match scenario { + CompletionScenario::Completed => ("child done".to_string(), "child done"), + CompletionScenario::TerminalError => ( + format!( + "Agent errored: {error}\n\nThis agent's turn failed. If you still need this agent, use the available collaboration tools to give it another task." + ), + error, + ), + }; + let notification = format!( + "Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/worker\nPayload:\n{payload}" + ); // If the child is still running when the parent turn starts, wait_agent blocks // until mailbox delivery. The follow-up request must then contain that delivery. mount_sse_once_match( &server, |req: &wiremock::Request| { body_contains(req, TURN_2_NO_WAIT_PROMPT) - && !body_contains(req, "") + && !body_contains(req, "Message Type: FINAL_ANSWER") }, sse(vec![ ev_response_created("resp-parent-3"), @@ -1159,7 +1191,8 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()> &server, |req: &wiremock::Request| { body_contains(req, TURN_2_NO_WAIT_PROMPT) - && body_contains(req, "") + && body_contains(req, "Message Type: FINAL_ANSWER") + && body_contains(req, expected_text) }, sse(vec![ ev_response_created("resp-parent-4"), @@ -1179,6 +1212,9 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()> .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + config.model_provider.supports_websockets = false; }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index c64251e24aa2..ec86ce5946ce 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1,9 +1,13 @@ 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::EventMsg; use codex_protocol::protocol::Op; use core_test_support::PathBufExt; +use core_test_support::assert_regex_match; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ResponsesRequest; @@ -16,24 +20,51 @@ 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 { request .message_input_texts("developer") .into_iter() - .filter(|text| text.starts_with("")) + .filter(|text| text.starts_with("Thread id ")) .collect() } +fn token_budget_window_ids( + text: &str, + thread_id: codex_protocol::ThreadId, +) -> (String, Option, String) { + let captures = assert_regex_match( + &format!( + r"^Thread 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}}))?$" + ), + 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() @@ -80,25 +111,106 @@ 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 expected = vec![format!( - "\nThread id {thread_id}.\nCurrent context window 0.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window.\n" - )]; + let initial_token_budget = token_budget_contexts(&requests[0]); + assert_eq!(initial_token_budget.len(), 1); + 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[0]), - expected, - "initial full context should report context window 0" + token_budget_contexts(&requests[1]), + initial_token_budget, + "steady-state context update should not advance the context window" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +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"^Thread 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$" + ), + &token_budgets[0], ); assert_eq!( - token_budget_texts(&requests[1]), - expected, - "steady-state context update should not advance the context window" + 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_remaining_context_emits_on_first_threshold_crossing() -> Result<()> { +async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -107,27 +219,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) @@ -136,48 +240,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 = format!( - "\nThread id {thread_id}.\nCurrent context window 0.\nYou have 9500 tokens left in this context window.\n" - ); - 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!( - 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!(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[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(()) } @@ -233,16 +312,10 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu ); let thread_id = test.session_configured.thread_id; - let full_context = format!( - "\nThread id {thread_id}.\nCurrent context window 0.\nYou have 9500 tokens left in this context window.\n" - ); - let remaining_context = - "\nYou have 7000 tokens left in this context window.\n" - .to_string(); - assert_eq!( - token_budget_texts(&requests[1]), - vec![full_context, remaining_context.clone()] - ); + 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)) @@ -299,14 +372,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, )) ); @@ -362,13 +432,25 @@ 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_contexts(&requests[0]); + assert_eq!(initial_token_budget.len(), 1); + 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_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!( - token_budget_texts(&requests[2]), - vec![format!( - "\nThread id {thread_id}.\nCurrent context window 1.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window.\n" - )], - "post-compaction full context should report context window 1" + post_compaction_previous_window_id.as_deref(), + Some(initial_window_id.as_str()) ); + assert_ne!(post_compaction_window_id, initial_window_id); Ok(()) } @@ -429,12 +511,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_contexts(&requests[0]); + assert_eq!(initial_token_budget.len(), 1); + 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 (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!( - token_budget_texts(&requests[2]), - vec![format!( - "\nThread id {thread_id}.\nCurrent context window 1.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window.\n" - )] + 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" @@ -448,7 +538,10 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { &[("Final Follow-Up Request", &requests[2])], &ContextSnapshotOptions::default(), ); - let snapshot = snapshot.replace(&thread_id.to_string(), ""); + let snapshot = snapshot + .replace(&thread_id.to_string(), "") + .replace(&new_first_window_id, "") + .replace(&new_window_id, ""); insta::assert_snapshot!( "token_budget_new_context_window_tool_full_context", snapshot @@ -456,3 +549,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 cc942cbc17c2..c7e296ec0cb3 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 b9a295c7e806..5c043059261c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -74,7 +74,7 @@ fn parse_unified_exec_output(raw: &str) -> Result { static OUTPUT_REGEX: OnceLock = OnceLock::new(); let regex = OUTPUT_REGEX.get_or_init(|| { Regex::new(concat!( - r#"(?s)^(?:Total output lines: \d+\n\n)?"#, + r#"(?s)^(?:Warning: truncated output \(original token count: \d+\)\n)?(?:Total output lines: \d+\n\n)?"#, r#"(?:Chunk ID: (?P[^\n]+)\n)?"#, r#"Wall time: (?P-?\d+(?:\.\d+)?) seconds\n"#, r#"(?:Process exited with code (?P-?\d+)\n)?"#, @@ -516,7 +516,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.as_path(), cwd.as_path()); + assert_eq!(begin_event.cwd, PathUri::from_path(&cwd)?); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -586,8 +586,8 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should resolve relative workdir against turn cwd", ); @@ -600,7 +600,6 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "flaky"] async fn unified_exec_respects_workdir_override() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -649,8 +648,8 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should reflect the requested workdir override" ); @@ -1954,7 +1953,7 @@ async fn exec_command_clamps_model_requested_max_output_tokens_to_policy() -> Re assert_eq!(output.original_token_count, Some(8_991)); let output_text = output.output.replace("\r\n", "\n"); assert_regex_match( - r"^Total output lines: 999\n\nEXEC-LINE-0001 x{20}\nEXEC-LINE-0002 x{20}\nEXEC-LINE-0003 x{13}…8941 tokens truncated…E-0997 x{20}\nEXEC-LINE-0998 x{20}\nEXEC-LINE-0999 x{20}\n$", + r"^Warning: truncated output \(original token count: 8991\)\nTotal output lines: 999\n\nEXEC-LINE-0001 x{20}\nEXEC-LINE-0002 x{20}\nEXEC-LINE-0003 x{13}…8941 tokens truncated…E-0997 x{20}\nEXEC-LINE-0998 x{20}\nEXEC-LINE-0999 x{20}\n$", &output_text, ); @@ -2042,10 +2041,13 @@ async fn write_stdin_clamps_model_requested_max_output_tokens_to_policy() -> Res ); let stdin_output = wait_for_raw_unified_exec_output(&test, stdin_call_id).await?; - assert_eq!(stdin_output.original_token_count, Some(9_492)); + let original_token_count = stdin_output + .original_token_count + .expect("missing original_token_count"); + assert!(original_token_count > 1_000); let stdin_output_text = stdin_output.output.replace("\r\n", "\n"); assert_regex_match( - r"^Total output lines: 1000\n\ngo\nSTDIN-LINE-0001 y{20}\nSTDIN-LINE-0002 y{20}\nSTDIN-LINE-0003 yyyy…9442 tokens truncated…7 y{20}\nSTDIN-LINE-0998 y{20}\nSTDIN-LINE-0999 y{20}\n$", + r"(?s)^Warning: truncated output \(original token count: \d+\)\nTotal output lines: \d+\n\ngo\nSTDIN-LINE-0001 y{20}\nSTDIN-LINE-0002 y{20}\nSTDIN-LINE-0003 .*…\d+ tokens truncated….*STDIN-LINE-0998 y{20}\nSTDIN-LINE-0999 y{20}\n$", &stdin_output_text, ); @@ -3360,7 +3362,7 @@ PY let large_output = outputs.get(call_id).expect("missing large output summary"); let output_text = large_output.output.replace("\r\n", "\n"); - let truncated_pattern = r"(?s)^Total output lines: \d+\n\n(token token \n){5,}.*…\d+ tokens truncated….*(token token \n){5,}$"; + let truncated_pattern = r"(?s)^Warning: truncated output \(original token count: \d+\)\nTotal output lines: \d+\n\n(token token \n){5,}.*…\d+ tokens truncated….*(token token \n){5,}$"; assert_regex_match(truncated_pattern, &output_text); let original_tokens = large_output diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index e66c674f92a1..a6612a20c058 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -21,14 +21,14 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { let mut config = load_default_config_for_test(&home).await; config .features - .enable(Feature::ChildAgentsMd) + .enable(Feature::ApplyPatchStreamingEvents) .expect("test config should allow feature update"); let user_config_path = AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) .expect("absolute user config path"); config.config_layer_stack = config.config_layer_stack.with_user_config( &user_config_path, - toml! { features = { child_agents_md = true } }.into(), + toml! { features = { apply_patch_streaming_events = true } }.into(), ); let thread_manager = codex_core::test_support::thread_manager_with_models_provider( @@ -47,6 +47,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { InitialHistory::New, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("spawn conversation"); @@ -55,7 +56,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { let EventMsg::Warning(WarningEvent { message }) = warning else { panic!("expected warning event"); }; - assert!(message.contains("child_agents_md")); + assert!(message.contains("apply_patch_streaming_events")); assert!(message.contains("Under-development features enabled")); assert!(message.contains("suppress_unstable_features_warning = true")); } @@ -66,7 +67,7 @@ async fn suppresses_warning_when_configured() { let mut config = load_default_config_for_test(&home).await; config .features - .enable(Feature::ChildAgentsMd) + .enable(Feature::ApplyPatchStreamingEvents) .expect("test config should allow feature update"); config.suppress_unstable_features_warning = true; let user_config_path = @@ -74,7 +75,7 @@ async fn suppresses_warning_when_configured() { .expect("absolute user config path"); config.config_layer_stack = config.config_layer_stack.with_user_config( &user_config_path, - toml! { features = { child_agents_md = true } }.into(), + toml! { features = { apply_patch_streaming_events = true } }.into(), ); let thread_manager = codex_core::test_support::thread_manager_with_models_provider( @@ -93,6 +94,7 @@ async fn suppresses_warning_when_configured() { InitialHistory::New, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("spawn conversation"); diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 4a9605497b15..174aba27e492 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -476,8 +476,9 @@ async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<( let head = (1..=69).map(|i| format!("{i}\n")).collect::(); let tail = (352..=400).map(|i| format!("{i}\n")).collect::(); - let truncated_body = - format!("Total output lines: 400\n\n{head}70…273 tokens truncated…351\n{tail}"); + let truncated_body = format!( + "Warning: truncated output (original token count: 373)\nTotal output lines: 400\n\n{head}70…273 tokens truncated…351\n{tail}" + ); let escaped_command = escape(&command); let escaped_truncated_body = escape(&truncated_body); let expected_pattern = format!( diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 1857eedefd64..c2c7b562d81e 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; @@ -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)] @@ -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/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 529cee8b8b14..5728b97a74e7 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -274,3 +274,42 @@ location = { country = "US", city = "New York", timezone = "America/New_York" } }) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn indexed_web_search_mode_sets_index_gate() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let sse = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + let resp_mock = responses::mount_sse_once(&server, sse).await; + + let home = Arc::new(tempfile::TempDir::new().expect("create codex home")); + std::fs::write(home.path().join("config.toml"), r#"web_search = "indexed""#) + .expect("write config.toml"); + + let mut builder = test_codex().with_model("gpt-5.3-codex").with_home(home); + let test = builder + .build(&server) + .await + .expect("create test Codex conversation"); + + test.submit_turn_with_permission_profile( + "hello indexed web search", + PermissionProfile::Disabled, + ) + .await + .expect("submit turn"); + + let body = resp_mock.single_request().body_json(); + let tool = find_web_search_tool(&body); + assert_eq!( + ( + tool.get("external_web_access").and_then(Value::as_bool), + tool.get("index_gated_web_access").and_then(Value::as_bool), + ), + (Some(true), Some(true)) + ); +} diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index 74d7f6a23bbc..b8895f49e4e1 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -173,6 +173,7 @@ async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> a capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, windows_sandbox_private_desktop: false, @@ -262,6 +263,7 @@ async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyh capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Elevated, windows_sandbox_private_desktop: false, diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 3c5408ba7ae7..31fc4ea0aa9e 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -28,6 +28,7 @@ codex-utils-path-uri = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } +http = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } prost = "0.14.3" serde = { workspace = true, features = ["derive"] } @@ -45,11 +46,20 @@ tokio = { workspace = true, features = [ "sync", "time", ] } -tokio-util = { workspace = true, features = ["rt"] } +tokio-util = { workspace = true, features = ["io", "rt"] } tokio-tungstenite = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", +] } + [dev-dependencies] anyhow = { workspace = true } codex-test-binary-support = { workspace = true } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 0f70b85dff7d..175097c96f47 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -345,6 +345,8 @@ invalid or unavailable paths. For compatibility, requests also accept native absolute path strings and normalize them to `file:` URIs: - `fs/readFile` +- `fs/open`, `fs/readBlock`, and `fs/close` (internal transport for + `ExecutorFileSystem::read_file_stream`) - `fs/writeFile` - `fs/createDirectory` - `fs/getMetadata` diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 5c9a5166ff4b..25d1b54e8bba 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use std::time::Duration; use arc_swap::ArcSwap; @@ -12,9 +14,10 @@ use futures::FutureExt; use futures::future::BoxFuture; use serde_json::Value; use tokio::sync::Mutex; -use tokio::sync::Semaphore; +use tokio::sync::OnceCell; use tokio::sync::mpsc; use tokio::sync::watch; +use tokio_util::task::AbortOnDropHandle; use tokio::time::timeout; use tracing::debug; @@ -25,6 +28,7 @@ use crate::client_api::ExecServerTransportParams; use crate::client_api::HttpClient; use crate::client_api::RemoteExecServerConnectArgs; use crate::client_api::StdioExecServerConnectArgs; +use crate::client_transport::ExecServerReconnectStrategy; use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; use crate::process::ExecProcessEventLog; @@ -45,21 +49,30 @@ use crate::protocol::ExecOutputDeltaNotification; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::FS_CANONICALIZE_METHOD; +use crate::protocol::FS_CLOSE_METHOD; use crate::protocol::FS_COPY_METHOD; use crate::protocol::FS_CREATE_DIRECTORY_METHOD; use crate::protocol::FS_GET_METADATA_METHOD; +use crate::protocol::FS_OPEN_METHOD; +use crate::protocol::FS_READ_BLOCK_METHOD; use crate::protocol::FS_READ_DIRECTORY_METHOD; use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FS_REMOVE_METHOD; use crate::protocol::FS_WRITE_FILE_METHOD; use crate::protocol::FsCanonicalizeParams; use crate::protocol::FsCanonicalizeResponse; +use crate::protocol::FsCloseParams; +use crate::protocol::FsCloseResponse; use crate::protocol::FsCopyParams; use crate::protocol::FsCopyResponse; use crate::protocol::FsCreateDirectoryParams; use crate::protocol::FsCreateDirectoryResponse; use crate::protocol::FsGetMetadataParams; use crate::protocol::FsGetMetadataResponse; +use crate::protocol::FsOpenParams; +use crate::protocol::FsOpenResponse; +use crate::protocol::FsReadBlockParams; +use crate::protocol::FsReadBlockResponse; use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadDirectoryResponse; use crate::protocol::FsReadFileParams; @@ -86,9 +99,10 @@ use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; use crate::rpc::RpcClient; -use crate::rpc::RpcClientEvent; pub(crate) mod http_client; +#[path = "client_recovery.rs"] +mod recovery; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); @@ -141,16 +155,20 @@ pub(crate) struct SessionState { wake_tx: watch::Sender, events: ExecProcessEventLog, ordered_events: StdMutex, - failure: Mutex>, + recoverable: AtomicBool, + next_write_id: AtomicU64, } #[derive(Default)] struct OrderedSessionEvents { last_published_seq: u64, + exit_published: bool, + closed_published: bool, // Server-side output, exit, and closed notifications are emitted by // different tasks and can reach the client out of order. Keep future events // here until all lower sequence numbers have been published. pending: BTreeMap, + failure: Option, } #[derive(Clone)] @@ -161,7 +179,8 @@ pub(crate) struct Session { } struct Inner { - client: RpcClient, + connection: StdMutex, + connection_changed: watch::Sender<()>, // The remote transport delivers one shared notification stream for every // process on the connection. Keep a local process_id -> session registry so // we can turn those connection-global notifications into process wakeups @@ -170,11 +189,7 @@ struct Inner { // ArcSwap makes reads cheap on the hot notification path, but writes still // need serialization so concurrent register/remove operations do not // overwrite each other's copy-on-write updates. - sessions_write_lock: Mutex<()>, - // Once the transport closes, every environment operation should fail quickly - // with the same canonical message. This client never reconnects, so the - // latch only moves from unset to set once. - disconnected: OnceLock, + sessions_write_lock: StdMutex<()>, // Streaming HTTP responses are keyed by a client-generated request id // because they share the same connection-global notification channel as // process output. Keep the routing table local to the client so higher @@ -183,14 +198,19 @@ struct Inner { http_body_stream_failures: ArcSwap>, http_body_streams_write_lock: Mutex<()>, http_body_stream_next_id: AtomicU64, - session_id: std::sync::RwLock>, - reader_task: tokio::task::JoinHandle<()>, + session_id: OnceLock, + reconnect_strategy: Option, } -impl Drop for Inner { - fn drop(&mut self) { - self.reader_task.abort(); - } +struct ConnectionState { + status: ConnectionStatus, + active_process_starts: usize, +} + +enum ConnectionStatus { + Connected(Arc), + Recovering, + Failed(String), } #[derive(Clone)] @@ -198,54 +218,142 @@ pub struct ExecServerClient { inner: Arc, } +struct ActiveProcessStart { + inner: Arc, +} + +impl Drop for ActiveProcessStart { + fn drop(&mut self) { + self.inner.finish_process_start(); + } +} + +type ConnectionResult = Result>; +type ConnectionAttempt = OnceCell; + #[derive(Clone)] pub(crate) struct LazyRemoteExecServerClient { transport_params: ExecServerTransportParams, - client: Arc>>, - connect_lock: Arc, + // Saves the first startup result so callers share it and failures remain final. + startup: Arc, + // The latest successful client, replaced whenever reconnecting succeeds. + current_client: Arc>>, + reconnect: Arc>>>, } impl LazyRemoteExecServerClient { pub(crate) fn new(transport_params: ExecServerTransportParams) -> Self { Self { transport_params, - client: Arc::new(StdMutex::new(None)), - connect_lock: Arc::new(Semaphore::new(/*permits*/ 1)), + startup: Arc::new(ConnectionAttempt::new()), + current_client: Arc::new(StdMutex::new(None)), + reconnect: Arc::new(StdMutex::new(None)), } } - pub(crate) async fn get(&self) -> Result { - if let Some(client) = self.connected_client() { - return Ok(client); + pub(crate) fn start_connecting(&self) -> Option> { + // Stdio starts a process, so keep it lazy until the environment is used. + if matches!( + self.transport_params, + ExecServerTransportParams::StdioCommand { .. } + ) { + return None; } + let client = self.clone(); + Some(AbortOnDropHandle::new(tokio::spawn(async move { + if let Err(error) = client.wait_until_ready().await { + debug!(%error, "exec-server environment startup failed"); + } + }))) + } - let _connect_permit = self.connect_lock.acquire().await.map_err(|_| { - ExecServerError::Protocol("exec-server connect lock closed".to_string()) - })?; + pub(crate) fn startup_finished(&self) -> bool { + self.startup.get().is_some() + } + + pub(crate) async fn wait_until_ready(&self) -> Result<(), ExecServerError> { + self.initial_client().await.map(drop) + } + + pub(crate) async fn get(&self) -> Result { if let Some(client) = self.connected_client() { return Ok(client); } - let next_client = match self.cached_client() { - Some(_client) - if matches!( - &self.transport_params, - ExecServerTransportParams::WebSocketUrl { .. } - | ExecServerTransportParams::NoiseRendezvous { .. } - ) => - { - ExecServerClient::connect_for_transport(self.transport_params.clone()).await? + let Some(cached_client) = self.cached_client() else { + let client = self.initial_client().await?; + if !client.is_disconnected() || !self.can_reconnect() { + return Ok(client); } - Some(client) => return Ok(client), - None => ExecServerClient::connect_for_transport(self.transport_params.clone()).await?, + return self.reconnect().await; }; - let mut cached_client = self - .client + if !self.can_reconnect() { + return Ok(cached_client); + } + + self.reconnect().await + } + + async fn initial_client(&self) -> Result { + // The first caller starts the work; every other caller waits for that same result. + let result = self + .startup + .get_or_init(|| connect_once(self.transport_params.clone())) + .await; + match result { + Ok(client) => { + let mut current_client = self + .current_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current_client.is_none() { + *current_client = Some(client.clone()); + } + Ok(client.clone()) + } + Err(error) => Err(ExecServerError::ConnectionAttempt(Arc::clone(error))), + } + } + + async fn reconnect(&self) -> Result { + // Callers handling the same outage share one reconnect attempt. + let attempt = { + let mut reconnect = self + .reconnect + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(client) = self.connected_client() { + return Ok(client); + } + reconnect + .get_or_insert_with(|| Arc::new(ConnectionAttempt::new())) + .clone() + }; + let result = attempt + .get_or_init(|| async { + let result = connect_once(self.transport_params.clone()).await; + if let Ok(client) = &result { + *self + .current_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(client.clone()); + } + result + }) + .await; + let mut reconnect = self + .reconnect .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - *cached_client = Some(next_client.clone()); - Ok(next_client) + // Forget only this completed attempt so a later operation can retry after failure. + if reconnect + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &attempt)) + { + *reconnect = None; + } + result.clone().map_err(ExecServerError::ConnectionAttempt) } fn connected_client(&self) -> Option { @@ -254,11 +362,25 @@ impl LazyRemoteExecServerClient { } fn cached_client(&self) -> Option { - self.client + self.current_client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } + + fn can_reconnect(&self) -> bool { + matches!( + self.transport_params, + ExecServerTransportParams::WebSocketUrl { .. } + | ExecServerTransportParams::NoiseRendezvous { .. } + ) + } +} + +async fn connect_once(transport_params: ExecServerTransportParams) -> ConnectionResult { + ExecServerClient::connect_for_transport(transport_params) + .await + .map_err(Arc::new) } impl HttpClient for LazyRemoteExecServerClient { @@ -324,12 +446,23 @@ pub enum ExecServerError { EnvironmentRegistryAuth(String), #[error("environment registry request failed: {0}")] EnvironmentRegistryRequest(#[from] reqwest::Error), + #[error("exec-server connection attempt failed: {0}")] + ConnectionAttempt(#[source] Arc), } impl ExecServerClient { pub async fn initialize( &self, options: ExecServerClientConnectOptions, + ) -> Result { + let rpc_client = self.inner.rpc_client().await?; + self.initialize_rpc(&rpc_client, options).await + } + + async fn initialize_rpc( + &self, + rpc_client: &RpcClient, + options: ExecServerClientConnectOptions, ) -> Result { let ExecServerClientConnectOptions { client_name, @@ -338,9 +471,7 @@ impl ExecServerClient { } = options; timeout(initialize_timeout, async { - let response: InitializeResponse = self - .inner - .client + let response: InitializeResponse = rpc_client .call( INITIALIZE_METHOD, &InitializeParams { @@ -349,15 +480,19 @@ impl ExecServerClient { }, ) .await?; - { - let mut session_id = self - .inner - .session_id - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *session_id = Some(response.session_id.clone()); + let session_id = self + .inner + .session_id + .get_or_init(|| response.session_id.clone()); + if session_id != &response.session_id { + return Err(ExecServerError::Protocol(format!( + "exec-server initialized an unexpected session {}", + response.session_id + ))); } - self.notify_initialized().await?; + rpc_client + .notify(INITIALIZED_METHOD, &serde_json::json!({})) + .await?; Ok(response) }) .await @@ -382,12 +517,14 @@ impl ExecServerClient { &self, process_id: &ProcessId, chunk: Vec, + write_id: String, ) -> Result { self.call( EXEC_WRITE_METHOD, &WriteParams { process_id: process_id.clone(), chunk: chunk.into(), + write_id, }, ) .await @@ -430,6 +567,24 @@ impl ExecServerClient { self.call(FS_READ_FILE_METHOD, ¶ms).await } + pub async fn fs_open(&self, params: FsOpenParams) -> Result { + self.call(FS_OPEN_METHOD, ¶ms).await + } + + pub async fn fs_read_block( + &self, + params: FsReadBlockParams, + ) -> Result { + self.call(FS_READ_BLOCK_METHOD, ¶ms).await + } + + pub async fn fs_close( + &self, + params: FsCloseParams, + ) -> Result { + self.call(FS_CLOSE_METHOD, ¶ms).await + } + pub async fn fs_write_file( &self, params: FsWriteFileParams, @@ -476,14 +631,72 @@ impl ExecServerClient { self.call(FS_COPY_METHOD, ¶ms).await } + pub(crate) async fn start_process( + &self, + params: ExecParams, + ) -> Result { + loop { + let rpc_client = self.inner.rpc_client().await?; + if !self.inner.begin_process_start(&rpc_client) { + continue; + } + + let process_id = params.process_id.clone(); + let state = Arc::new(SessionState::new(/*recoverable*/ false)); + if let Err(error) = self.inner.insert_session(&process_id, Arc::clone(&state)) { + self.inner.finish_process_start(); + return Err(error); + } + let active_start = ActiveProcessStart { + inner: Arc::clone(&self.inner), + }; + let client = self.clone(); + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let _active_start = active_start; + match client + .call_rpc::<_, ExecResponse>(&rpc_client, EXEC_METHOD, ¶ms) + .await + { + Ok(_) => { + state.recoverable.store(true, Ordering::Release); + let session = Session { + client: client.clone(), + process_id: process_id.clone(), + state: Arc::clone(&state), + }; + if result_tx.send(Ok(session)).is_err() { + state.recoverable.store(false, Ordering::Release); + tokio::spawn(async move { + cleanup_process_start(&client, &process_id, &state).await; + }); + } + } + Err(error) => { + if is_transport_closed_error(&error) { + tokio::spawn(async move { + cleanup_process_start(&client, &process_id, &state).await; + }); + } else { + client.inner.remove_session_if(&process_id, &state); + } + let _ = result_tx.send(Err(error)); + } + } + }); + return result_rx.await.map_err(|_| { + ExecServerError::Protocol("process start task stopped unexpectedly".to_string()) + })?; + } + } + + #[cfg(test)] pub(crate) async fn register_session( &self, process_id: &ProcessId, ) -> Result { - let state = Arc::new(SessionState::new()); - self.inner - .insert_session(process_id, Arc::clone(&state)) - .await?; + let state = Arc::new(SessionState::new(/*recoverable*/ true)); + self.inner.insert_session(process_id, Arc::clone(&state))?; Ok(Session { client: self.clone(), process_id: process_id.clone(), @@ -491,109 +704,81 @@ impl ExecServerClient { }) } - pub(crate) async fn unregister_session(&self, process_id: &ProcessId) { - self.inner.remove_session(process_id).await; - } - pub fn session_id(&self) -> Option { - self.inner - .session_id - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + self.inner.session_id.get().cloned() } fn is_disconnected(&self) -> bool { - self.inner.disconnected.get().is_some() || self.inner.client.is_disconnected() + self.inner.is_failed() } pub(crate) async fn connect( connection: JsonRpcConnection, options: ExecServerClientConnectOptions, ) -> Result { - let (rpc_client, mut events_rx) = RpcClient::new(connection); - let inner = Arc::new_cyclic(|weak| { - let weak = weak.clone(); - let reader_task = tokio::spawn(async move { - while let Some(event) = events_rx.recv().await { - match event { - RpcClientEvent::Notification(notification) => { - if let Some(inner) = weak.upgrade() - && let Err(err) = - handle_server_notification(&inner, notification).await - { - let message = record_disconnected( - &inner, - format!("exec-server notification handling failed: {err}"), - ); - fail_all_in_flight_work(&inner, message).await; - return; - } - } - RpcClientEvent::Disconnected { reason } => { - if let Some(inner) = weak.upgrade() { - let message = record_disconnected( - &inner, - disconnected_message(reason.as_deref()), - ); - fail_all_in_flight_work(&inner, message).await; - } - return; - } - } - } - }); + Self::connect_with_recovery(connection, options, /*reconnect_strategy*/ None).await + } - Inner { - client: rpc_client, - sessions: ArcSwap::from_pointee(HashMap::new()), - sessions_write_lock: Mutex::new(()), - disconnected: OnceLock::new(), - http_body_streams: ArcSwap::from_pointee(HashMap::new()), - http_body_stream_failures: ArcSwap::from_pointee(HashMap::new()), - http_body_streams_write_lock: Mutex::new(()), - http_body_stream_next_id: AtomicU64::new(1), - session_id: std::sync::RwLock::new(None), - reader_task, - } + pub(crate) async fn connect_with_recovery( + connection: JsonRpcConnection, + options: ExecServerClientConnectOptions, + reconnect_strategy: Option, + ) -> Result { + let (rpc_client, events_rx) = RpcClient::new(connection); + let rpc_client = Arc::new(rpc_client); + let session_id = OnceLock::new(); + let (connection_changed, _connection_changed_rx) = watch::channel(()); + let inner = Arc::new(Inner { + connection: StdMutex::new(ConnectionState { + status: ConnectionStatus::Connected(Arc::clone(&rpc_client)), + active_process_starts: 0, + }), + connection_changed, + sessions: ArcSwap::from_pointee(HashMap::new()), + sessions_write_lock: StdMutex::new(()), + http_body_streams: ArcSwap::from_pointee(HashMap::new()), + http_body_stream_failures: ArcSwap::from_pointee(HashMap::new()), + http_body_streams_write_lock: Mutex::new(()), + http_body_stream_next_id: AtomicU64::new(1), + session_id, + reconnect_strategy, }); - let client = Self { inner }; - client.initialize(options).await?; + // An explicit resume can redirect notifications from running processes + // before initialize returns. Drain them immediately so a burst cannot + // fill the bounded event channel and block the initialize response. + client.spawn_rpc_reader(&rpc_client, events_rx); + client.initialize_rpc(&rpc_client, options).await?; Ok(client) } - async fn notify_initialized(&self) -> Result<(), ExecServerError> { - self.inner - .client - .notify(INITIALIZED_METHOD, &serde_json::json!({})) - .await - .map_err(ExecServerError::Json) - } - async fn call(&self, method: &str, params: &P) -> Result where P: serde::Serialize, T: serde::de::DeserializeOwned, { - // Reject new work before allocating a JSON-RPC request id. MCP tool - // calls, process writes, and fs operations all pass through here, so - // this is the shared low-level failure path after environment disconnect. - if let Some(error) = self.inner.disconnected_error() { - return Err(error); - } + let rpc_client = self.inner.rpc_client().await?; + self.call_rpc(&rpc_client, method, params).await + } - match self.inner.client.call(method, params).await { + async fn call_rpc( + &self, + rpc_client: &Arc, + method: &str, + params: &P, + ) -> Result + where + P: serde::Serialize, + T: serde::de::DeserializeOwned, + { + match rpc_client.call(method, params).await { Ok(response) => Ok(response), Err(error) => { let error = ExecServerError::from(error); if is_transport_closed_error(&error) { - // A call can race with disconnect after the preflight - // check. Only the reader task drains sessions so queued - // process notifications stay ordered before disconnect. - let message = disconnected_message(/*reason*/ None); - let message = record_disconnected(&self.inner, message); - Err(ExecServerError::Disconnected(message)) + Err(ExecServerError::Disconnected(disconnected_message( + /*reason*/ None, + ))) } else { Err(error) } @@ -602,6 +787,23 @@ impl ExecServerClient { } } +async fn cleanup_process_start( + client: &ExecServerClient, + process_id: &ProcessId, + state: &Arc, +) { + loop { + match client.terminate(process_id).await { + Ok(_) => break, + Err(error) if is_transport_closed_error(&error) && !client.inner.is_failed() => { + continue; + } + Err(_) => break, + } + } + client.inner.remove_session_if(process_id, state); +} + impl From for ExecServerError { fn from(value: RpcCallError) -> Self { match value { @@ -616,7 +818,7 @@ impl From for ExecServerError { } impl SessionState { - fn new() -> Self { + fn new(recoverable: bool) -> Self { let (wake_tx, _wake_rx) = watch::channel(0); Self { wake_tx, @@ -625,7 +827,8 @@ impl SessionState { PROCESS_EVENT_RETAINED_BYTES, ), ordered_events: StdMutex::new(OrderedSessionEvents::default()), - failure: Mutex::new(None), + recoverable: AtomicBool::new(recoverable), + next_write_id: AtomicU64::new(1), } } @@ -638,8 +841,8 @@ impl SessionState { } fn note_change(&self, seq: u64) { - let next = (*self.wake_tx.borrow()).max(seq); - let _ = self.wake_tx.send(next); + self.wake_tx + .send_modify(|current| *current = (*current).max(seq)); } /// Publishes a process event only when all earlier sequenced events have @@ -655,55 +858,61 @@ impl SessionState { return false; }; - let mut ready = Vec::new(); + let mut ordered_events = self + .ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // We have already delivered this sequence number or moved past it, + // so accepting it again would duplicate output or lifecycle events. + if ordered_events.failure.is_some() + || ordered_events.closed_published + || seq <= ordered_events.last_published_seq { - let mut ordered_events = self - .ordered_events - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // We have already delivered this sequence number or moved past it, - // so accepting it again would duplicate output or lifecycle events. - if seq <= ordered_events.last_published_seq { - return false; - } - - ordered_events.pending.entry(seq).or_insert(event); - loop { - let next_seq = ordered_events.last_published_seq + 1; - let Some(event) = ordered_events.pending.remove(&next_seq) else { - break; - }; - ordered_events.last_published_seq += 1; - ready.push(event); - } + return false; } + ordered_events.pending.entry(seq).or_insert(event); + self.publish_ready(&mut ordered_events) + } + + fn publish_ready(&self, ordered_events: &mut OrderedSessionEvents) -> bool { let mut published_closed = false; - for event in ready { - published_closed |= matches!(&event, ExecProcessEvent::Closed { .. }); + loop { + let next_seq = ordered_events.last_published_seq.saturating_add(1); + let Some(event) = ordered_events.pending.remove(&next_seq) else { + break; + }; + ordered_events.last_published_seq = next_seq; + ordered_events.exit_published |= matches!(&event, ExecProcessEvent::Exited { .. }); + let is_closed = matches!(&event, ExecProcessEvent::Closed { .. }); + ordered_events.closed_published |= is_closed; + published_closed |= is_closed; self.events.publish(event); } published_closed } - async fn set_failure(&self, message: String) { - let mut failure = self.failure.lock().await; - let should_publish = failure.is_none(); - if should_publish { - *failure = Some(message.clone()); - } - drop(failure); - let next = (*self.wake_tx.borrow()).saturating_add(1); - let _ = self.wake_tx.send(next); - if should_publish { - let _ = self.publish_ordered_event(ExecProcessEvent::Failed(message)); + fn set_failure(&self, message: String) { + let mut ordered_events = self + .ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ordered_events.failure.is_some() || ordered_events.closed_published { + return; } + ordered_events.failure = Some(message.clone()); + ordered_events.pending.clear(); + self.events.publish(ExecProcessEvent::Failed(message)); + drop(ordered_events); + self.wake_tx + .send_modify(|current| *current = current.saturating_add(1)); } - async fn failed_response(&self) -> Option { - self.failure + fn failed_response(&self) -> Option { + self.ordered_events .lock() - .await + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failure .clone() .map(|message| self.synthesized_failure(message)) } @@ -717,8 +926,15 @@ impl SessionState { exit_code: None, closed: true, failure: Some(message), + sandbox_denied: false, } } + + fn next_write_id(&self) -> String { + self.next_write_id + .fetch_add(1, Ordering::Relaxed) + .to_string() + } } impl Session { @@ -740,32 +956,57 @@ impl Session { max_bytes: Option, wait_ms: Option, ) -> Result { - if let Some(response) = self.state.failed_response().await { - return Ok(response); - } + loop { + if let Some(response) = self.state.failed_response() { + return Ok(response); + } - match self - .client - .read(ReadParams { - process_id: self.process_id.clone(), - after_seq, - max_bytes, - wait_ms, - }) - .await - { - Ok(response) => Ok(response), - Err(err) if is_transport_closed_error(&err) => { - let message = disconnected_message(/*reason*/ None); - self.state.set_failure(message.clone()).await; - Ok(self.state.synthesized_failure(message)) + match self + .client + .read(ReadParams { + process_id: self.process_id.clone(), + after_seq, + max_bytes, + wait_ms, + }) + .await + { + Ok(response) => return Ok(response), + Err(error) + if is_transport_closed_error(&error) && !self.client.inner.is_failed() => + { + continue; + } + Err(error) if is_transport_closed_error(&error) => { + if let Some(response) = self.state.failed_response() { + return Ok(response); + } + let message = error.to_string(); + self.state.set_failure(message.clone()); + return Ok(self.state.synthesized_failure(message)); + } + Err(error) => return Err(error), } - Err(err) => Err(err), } } pub(crate) async fn write(&self, chunk: Vec) -> Result { - self.client.write(&self.process_id, chunk).await + let write_id = self.state.next_write_id(); + loop { + match self + .client + .write(&self.process_id, chunk.clone(), write_id.clone()) + .await + { + Ok(response) => return Ok(response), + Err(error) + if is_transport_closed_error(&error) && !self.client.inner.is_failed() => + { + continue; + } + Err(error) => return Err(error), + } + } } pub(crate) async fn signal(&self, signal: ProcessSignal) -> Result<(), ExecServerError> { @@ -778,40 +1019,31 @@ impl Session { } pub(crate) async fn unregister(&self) { - self.client.unregister_session(&self.process_id).await; + self.client + .inner + .remove_session_if(&self.process_id, &self.state); } } impl Inner { - fn disconnected_error(&self) -> Option { - self.disconnected - .get() - .cloned() - .map(ExecServerError::Disconnected) - } - - fn set_disconnected(&self, message: String) -> Option { - match self.disconnected.set(message.clone()) { - Ok(()) => Some(message), - Err(_) => None, - } - } - fn get_session(&self, process_id: &ProcessId) -> Option> { self.sessions.load().get(process_id).cloned() } - async fn insert_session( + fn insert_session( &self, process_id: &ProcessId, session: Arc, ) -> Result<(), ExecServerError> { - let _sessions_write_guard = self.sessions_write_lock.lock().await; + let _sessions_write_guard = self + .sessions_write_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); // Do not register a process session that can never receive environment // notifications. Without this check, remote MCP startup could create a // dead session and wait for process output that will never arrive. - if let Some(error) = self.disconnected_error() { - return Err(error); + if let Some(message) = self.failure_message() { + return Err(ExecServerError::Disconnected(message)); } let sessions = self.sessions.load(); if sessions.contains_key(process_id) { @@ -825,19 +1057,28 @@ impl Inner { Ok(()) } - async fn remove_session(&self, process_id: &ProcessId) -> Option> { - let _sessions_write_guard = self.sessions_write_lock.lock().await; + fn remove_session_if(&self, process_id: &ProcessId, expected: &Arc) { + let _sessions_write_guard = self + .sessions_write_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let sessions = self.sessions.load(); - let session = sessions.get(process_id).cloned(); - session.as_ref()?; + if !sessions + .get(process_id) + .is_some_and(|session| Arc::ptr_eq(session, expected)) + { + return; + } let mut next_sessions = sessions.as_ref().clone(); next_sessions.remove(process_id); self.sessions.store(Arc::new(next_sessions)); - session } - async fn take_all_sessions(&self) -> HashMap> { - let _sessions_write_guard = self.sessions_write_lock.lock().await; + fn take_all_sessions(&self) -> HashMap> { + let _sessions_write_guard = self + .sessions_write_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let sessions = self.sessions.load(); let drained_sessions = sessions.as_ref().clone(); self.sessions.store(Arc::new(HashMap::new())); @@ -865,31 +1106,20 @@ fn is_transport_closed_error(error: &ExecServerError) -> bool { ) } -fn record_disconnected(inner: &Arc, message: String) -> String { - // The first observer records the canonical disconnect reason. Session - // draining stays with the reader task so it can preserve notification - // ordering before publishing the terminal failure. - if let Some(message) = inner.set_disconnected(message.clone()) { - message - } else { - inner.disconnected.get().cloned().unwrap_or(message) - } -} - -async fn fail_all_sessions(inner: &Arc, message: String) { - let sessions = inner.take_all_sessions().await; +fn fail_all_sessions(inner: &Arc, message: String) { + let sessions = inner.take_all_sessions(); for (_, session) in sessions { // Sessions synthesize a closed read response and emit a pushed Failed // event. That covers both polling consumers and streaming consumers // such as environment-backed MCP stdio. - session.set_failure(message.clone()).await; + session.set_failure(message.clone()); } } /// Fails all in-flight work that depends on the shared JSON-RPC transport. async fn fail_all_in_flight_work(inner: &Arc, message: String) { - fail_all_sessions(inner, message.clone()).await; + fail_all_sessions(inner, message.clone()); inner.fail_all_http_body_streams(message).await; } @@ -910,7 +1140,7 @@ async fn handle_server_notification( chunk: params.chunk, })); if published_closed { - inner.remove_session(¶ms.process_id).await; + inner.remove_session_if(¶ms.process_id, &session); } } } @@ -924,7 +1154,7 @@ async fn handle_server_notification( exit_code: params.exit_code, }); if published_closed { - inner.remove_session(¶ms.process_id).await; + inner.remove_session_if(¶ms.process_id, &session); } } } @@ -939,7 +1169,7 @@ async fn handle_server_notification( let published_closed = session.publish_ordered_event(ExecProcessEvent::Closed { seq: params.seq }); if published_closed { - inner.remove_session(¶ms.process_id).await; + inner.remove_session_if(¶ms.process_id, &session); } } } @@ -978,6 +1208,7 @@ mod tests { use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::sync::oneshot; + use tokio::sync::watch; use tokio::time::Duration; #[cfg(unix)] use tokio::time::sleep; @@ -993,6 +1224,7 @@ mod tests { #[cfg(not(windows))] use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT; use crate::client_api::ExecServerTransportParams; + use crate::client_api::RemoteExecServerConnectArgs; use crate::client_api::StdioExecServerCommand; use crate::client_api::StdioExecServerConnectArgs; use crate::connection::JsonRpcConnection; @@ -1000,6 +1232,8 @@ mod tests { use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; + use crate::protocol::EXEC_READ_METHOD; + use crate::protocol::EXEC_WRITE_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; @@ -1008,6 +1242,10 @@ mod tests { use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeResponse; use crate::protocol::ProcessOutputChunk; + use crate::protocol::ReadResponse; + use crate::protocol::WriteParams; + use crate::protocol::WriteResponse; + use crate::protocol::WriteStatus; async fn read_jsonrpc_line(lines: &mut tokio::io::Lines>) -> JSONRPCMessage where @@ -1109,19 +1347,6 @@ mod tests { } } - async fn wait_for_disconnect(client: &ExecServerClient) { - timeout(Duration::from_secs(1), async { - loop { - if client.is_disconnected() { - return; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("client should observe disconnect"); - } - #[cfg(not(windows))] #[tokio::test] async fn connect_stdio_command_initializes_json_rpc_client() { @@ -1540,7 +1765,7 @@ mod tests { } #[tokio::test] - async fn remote_websocket_client_replaces_disconnected_client_with_fresh_session() { + async fn remote_websocket_client_resumes_session() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -1548,45 +1773,418 @@ mod tests { "ws://{}", listener.local_addr().expect("listener should have address") ); - let server = tokio::spawn({ - async move { - let mut first = accept_websocket(&listener).await; - complete_websocket_initialize( - &mut first, - "session-1", - /*expected_resume_session_id*/ None, - ) - .await; - first - .close(None) - .await - .expect("first websocket should close"); + let (resumed_tx, resumed_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let mut first = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut first, + "session-1", + /*expected_resume_session_id*/ None, + ) + .await; + first.close(None).await.expect("websocket should close"); + + let mut resumed = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut resumed, + "session-1", + /*expected_resume_session_id*/ Some("session-1"), + ) + .await; + resumed_tx.send(()).expect("resume should signal"); + finish_rx.await.expect("test should finish"); + }); + + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + }); + let stable_client = client.get().await.expect("client should connect"); + timeout(Duration::from_secs(1), resumed_rx) + .await + .expect("session resume should not time out") + .expect("session resume should signal"); + let reused_client = client.get().await.expect("client should stay connected"); + assert_eq!(stable_client.session_id().as_deref(), Some("session-1")); + assert!(Arc::ptr_eq(&stable_client.inner, &reused_client.inner)); + finish_tx.send(()).expect("test should finish"); + server.await.expect("server task should finish"); + } + + #[tokio::test] + async fn session_write_retries_same_write_id_after_recovery() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let (finish_tx, finish_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let mut first = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut first, + "session-1", + /*expected_resume_session_id*/ None, + ) + .await; + + let first_write = read_jsonrpc_websocket(&mut first).await; + let first_write = match first_write { + JSONRPCMessage::Request(request) if request.method == EXEC_WRITE_METHOD => request, + other => panic!("expected first process/write request, got {other:?}"), + }; + let first_write_params: WriteParams = + serde_json::from_value(first_write.params.expect("write params should exist")) + .expect("write params should deserialize"); + assert_eq!(first_write_params.process_id.as_str(), "proc-write"); + assert_eq!(first_write_params.chunk.into_inner(), b"hello\n".to_vec()); + let write_id = first_write_params.write_id; + assert!(!write_id.is_empty()); + drop(first); + + let mut resumed = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut resumed, + "session-1", + /*expected_resume_session_id*/ Some("session-1"), + ) + .await; + + let recovery_read = read_jsonrpc_websocket(&mut resumed).await; + let recovery_read = match recovery_read { + JSONRPCMessage::Request(request) if request.method == EXEC_READ_METHOD => request, + other => panic!("expected recovery process/read request, got {other:?}"), + }; + write_jsonrpc_websocket( + &mut resumed, + JSONRPCMessage::Response(JSONRPCResponse { + id: recovery_read.id, + result: serde_json::to_value(ReadResponse { + chunks: Vec::new(), + next_seq: 1, + exited: false, + exit_code: None, + closed: false, + failure: None, + sandbox_denied: false, + }) + .expect("read response should serialize"), + }), + ) + .await; - let mut second = accept_websocket(&listener).await; - complete_websocket_initialize( - &mut second, - "session-2", - /*expected_resume_session_id*/ None, + let retried_write = read_jsonrpc_websocket(&mut resumed).await; + let retried_write = match retried_write { + JSONRPCMessage::Request(request) if request.method == EXEC_WRITE_METHOD => request, + other => panic!("expected retried process/write request, got {other:?}"), + }; + let retried_write_params: WriteParams = + serde_json::from_value(retried_write.params.expect("write params should exist")) + .expect("write params should deserialize"); + assert_eq!(retried_write_params.process_id.as_str(), "proc-write"); + assert_eq!(retried_write_params.chunk.into_inner(), b"hello\n".to_vec()); + assert_eq!(retried_write_params.write_id, write_id); + write_jsonrpc_websocket( + &mut resumed, + JSONRPCMessage::Response(JSONRPCResponse { + id: retried_write.id, + result: serde_json::to_value(WriteResponse { + status: WriteStatus::Accepted, + }) + .expect("write response should serialize"), + }), + ) + .await; + + finish_rx.await.expect("test should finish"); + }); + + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + }); + let stable_client = client.get().await.expect("client should connect"); + let session = stable_client + .register_session(&ProcessId::from("proc-write")) + .await + .expect("session should register"); + + let response = timeout(Duration::from_secs(2), session.write(b"hello\n".to_vec())) + .await + .expect("write should not time out") + .expect("write should recover"); + assert_eq!( + response, + WriteResponse { + status: WriteStatus::Accepted + } + ); + + finish_tx.send(()).expect("test should finish"); + server.await.expect("server task should finish"); + } + + #[tokio::test] + async fn explicit_resume_drains_notifications_before_initialize_response() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let (initialized_tx, initialized_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let mut websocket = accept_websocket(&listener).await; + let initialize = read_jsonrpc_websocket(&mut websocket).await; + let request = match initialize { + JSONRPCMessage::Request(request) if request.method == INITIALIZE_METHOD => request, + other => panic!("expected initialize request, got {other:?}"), + }; + let params: crate::protocol::InitializeParams = + serde_json::from_value(request.params.expect("initialize params should exist")) + .expect("initialize params should deserialize"); + assert_eq!(params.resume_session_id.as_deref(), Some("session-1")); + + for seq in 1..=256 { + write_jsonrpc_websocket( + &mut websocket, + JSONRPCMessage::Notification(JSONRPCNotification { + method: EXEC_OUTPUT_DELTA_METHOD.to_string(), + params: Some( + serde_json::to_value(ExecOutputDeltaNotification { + process_id: ProcessId::from("busy-process"), + seq, + stream: ExecOutputStream::Stdout, + chunk: b"output".to_vec().into(), + }) + .expect("output notification should serialize"), + ), + }), ) .await; } + write_jsonrpc_websocket( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::to_value(InitializeResponse { + session_id: "session-1".to_string(), + }) + .expect("initialize response should serialize"), + }), + ) + .await; + + let initialized = read_jsonrpc_websocket(&mut websocket).await; + match initialized { + JSONRPCMessage::Notification(notification) + if notification.method == INITIALIZED_METHOD => {} + other => panic!("expected initialized notification, got {other:?}"), + } + initialized_tx + .send(()) + .expect("initialized notification should signal"); + finish_rx.await.expect("test should finish"); + }); + + let client = timeout( + Duration::from_secs(1), + ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { + websocket_url, + client_name: "test-client".to_string(), + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + resume_session_id: Some("session-1".to_string()), + }), + ) + .await + .expect("explicit resume should not time out") + .expect("explicit resume should connect"); + assert_eq!(client.session_id().as_deref(), Some("session-1")); + + timeout(Duration::from_secs(1), initialized_rx) + .await + .expect("initialized notification should not time out") + .expect("initialized notification should signal"); + finish_tx.send(()).expect("test should finish"); + server.await.expect("server task should finish"); + } + + #[tokio::test] + async fn initial_connection_is_shared_by_all_waiters() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let server = tokio::spawn(async move { + let mut connection = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut connection, + "startup-session", + /*expected_resume_session_id*/ None, + ) + .await; + timeout(Duration::from_secs(1), connection.next()) + .await + .expect("client should close after the test"); + }); + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + }); + + assert!(!client.startup_finished()); + let _startup_task = client.start_connecting(); + let (ready, first, second) = + tokio::join!(client.wait_until_ready(), client.get(), client.get()); + ready.expect("background startup should finish"); + let first = first.expect("first waiter should receive the client"); + let second = second.expect("second waiter should receive the same client"); + + assert!(client.startup_finished()); + assert_eq!(first.session_id().as_deref(), Some("startup-session")); + assert!(Arc::ptr_eq(&first.inner, &second.inner)); + + drop(first); + drop(second); + drop(client); + server.await.expect("server task should finish"); + } + + #[tokio::test] + async fn terminal_stdio_startup_failure_is_remembered() { + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program: "codex-missing-exec-server-for-test".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: None, + }, + initialize_timeout: Duration::from_secs(1), }); + assert!(client.start_connecting().is_none()); + assert!(!client.startup_finished()); + let first = match client.get().await { + Ok(_) => panic!("missing executable should fail"), + Err(error) => error, + }; + assert!(client.startup_finished()); + let second = match client.get().await { + Ok(_) => panic!("burned environment should stay failed"), + Err(error) => error, + }; + + let ( + super::ExecServerError::ConnectionAttempt(first), + super::ExecServerError::ConnectionAttempt(second), + ) = (first, second) + else { + panic!("expected saved connection failures"); + }; + assert!(Arc::ptr_eq(&first, &second)); + } + + #[tokio::test] + async fn failed_reconnect_does_not_burn_environment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let (replacement_initialized_tx, replacement_initialized_rx) = oneshot::channel(); + let (allow_replacement_tx, allow_replacement_rx) = watch::channel(false); + let server = tokio::spawn(async move { + let mut first = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut first, + "startup-session", + /*expected_resume_session_id*/ None, + ) + .await; + first + .close(None) + .await + .expect("startup websocket should close"); + + let successful_reconnect = loop { + let (stream, _) = listener.accept().await.expect("reconnect should arrive"); + if *allow_replacement_rx.borrow() { + break stream; + } + let mut failed_reconnect = stream; + failed_reconnect + .write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n") + .await + .expect("failed handshake response should write"); + }; + let mut successful_reconnect = accept_async(successful_reconnect) + .await + .expect("replacement websocket handshake should succeed"); + complete_websocket_initialize( + &mut successful_reconnect, + "replacement-session", + /*expected_resume_session_id*/ None, + ) + .await; + replacement_initialized_tx + .send(()) + .expect("replacement initialization should be observed"); + timeout(Duration::from_secs(1), successful_reconnect.next()) + .await + .expect("client should close after the test"); + }); let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { websocket_url, connect_timeout: Duration::from_secs(1), initialize_timeout: Duration::from_secs(1), }); - let first = client.get().await.expect("first client should connect"); - wait_for_disconnect(&first).await; - let (replacement_a, replacement_b) = tokio::join!(client.get(), client.get()); - let replacement_a = replacement_a.expect("first replacement should connect"); - let replacement_b = replacement_b.expect("second replacement should reuse client"); - assert_eq!(replacement_a.session_id().as_deref(), Some("session-2")); - assert_eq!(replacement_b.session_id().as_deref(), Some("session-2")); - assert!(Arc::ptr_eq(&replacement_a.inner, &replacement_b.inner)); + let initial = client.get().await.expect("startup should connect"); + timeout(Duration::from_secs(1), async { + while !initial.is_disconnected() { + tokio::task::yield_now().await; + } + }) + .await + .expect("client should observe disconnect"); + let failed_reconnect = match client.get().await { + Ok(_) => panic!("first lazy reconnect should fail"), + Err(error) => error, + }; + assert!(matches!( + failed_reconnect, + super::ExecServerError::ConnectionAttempt(_) + )); + allow_replacement_tx + .send(true) + .expect("server should allow a fresh client"); + let replacement = client.get().await.expect("later reconnect should succeed"); + + assert_eq!( + replacement.session_id().as_deref(), + Some("replacement-session") + ); + replacement_initialized_rx + .await + .expect("server should observe replacement initialization"); + drop(initial); + drop(replacement); + drop(client); server.await.expect("server task should finish"); } diff --git a/codex-rs/exec-server/src/client/rpc_http_client.rs b/codex-rs/exec-server/src/client/rpc_http_client.rs index d2ce842ca9d7..d243a8d42f9c 100644 --- a/codex-rs/exec-server/src/client/rpc_http_client.rs +++ b/codex-rs/exec-server/src/client/rpc_http_client.rs @@ -42,6 +42,7 @@ impl ExecServerClient { &self, mut params: HttpRequestParams, ) -> Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError> { + let rpc_client = self.inner.rpc_client().await?; params.stream_response = true; let request_id = self.inner.next_http_body_stream_request_id(); params.request_id = request_id.clone(); @@ -51,7 +52,10 @@ impl ExecServerClient { .await?; let mut registration = HttpBodyStreamRegistration::new(Arc::clone(&self.inner), request_id.clone()); - let response = match self.call(HTTP_REQUEST_METHOD, ¶ms).await { + let response = match self + .call_rpc(&rpc_client, HTTP_REQUEST_METHOD, ¶ms) + .await + { Ok(response) => response, Err(error) => { self.inner.remove_http_body_stream(&request_id).await; diff --git a/codex-rs/exec-server/src/client_api.rs b/codex-rs/exec-server/src/client_api.rs index 60dc638d4d71..0c6a3576d026 100644 --- a/codex-rs/exec-server/src/client_api.rs +++ b/codex-rs/exec-server/src/client_api.rs @@ -136,10 +136,10 @@ impl std::fmt::Debug for ExecServerTransportParams { } impl ExecServerTransportParams { - pub(crate) fn websocket_url(websocket_url: String) -> Self { + pub(crate) fn websocket_url(websocket_url: String, connect_timeout: Duration) -> Self { Self::WebSocketUrl { websocket_url, - connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + connect_timeout, initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, } } diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs new file mode 100644 index 000000000000..f58e365728cd --- /dev/null +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -0,0 +1,555 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::Hash; +use std::hash::Hasher; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout_at; + +use super::ConnectionStatus; +use super::ExecServerClient; +use super::ExecServerError; +use super::Inner; +use super::OrderedSessionEvents; +use super::SessionState; +use super::disconnected_message; +use super::fail_all_in_flight_work; +use super::handle_server_notification; +use super::is_transport_closed_error; +use crate::client_transport::ExecServerReconnectStrategy; +use crate::process::ExecProcessEvent; +use crate::protocol::EXEC_READ_METHOD; +use crate::protocol::EXEC_TERMINATE_METHOD; +use crate::protocol::ReadParams; +use crate::protocol::ReadResponse; +use crate::protocol::TerminateParams; +use crate::protocol::TerminateResponse; +use crate::rpc::RpcClient; +use crate::rpc::RpcClientEvent; +use crate::rpc::SESSION_ALREADY_ATTACHED_ERROR_CODE; + +#[cfg(test)] +const SESSION_RECOVERY_TIMEOUT: Duration = Duration::from_millis(500); +#[cfg(not(test))] +// Leave margin inside the server's 30-second retention windows because the +// client and server start their disconnect clocks independently. +const SESSION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(25); +const SESSION_RECOVERY_RETRY_INTERVAL: Duration = Duration::from_millis(100); +const REGISTRY_RECOVERY_INITIAL_RETRY_INTERVAL: Duration = Duration::from_millis(500); +const REGISTRY_RECOVERY_MAX_RETRY_INTERVAL: Duration = Duration::from_secs(5); + +impl SessionState { + fn last_published_seq(&self) -> u64 { + self.ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .last_published_seq + } + + fn recover_events(&self, response: ReadResponse) -> Result { + let ReadResponse { + chunks, + next_seq, + exited, + exit_code, + closed, + failure, + sandbox_denied: _, + } = response; + if let Some(message) = failure { + return Err(ExecServerError::Protocol(format!( + "process failed while recovering: {message}" + ))); + } + + let target_seq = next_seq.saturating_sub(1); + let published_closed = { + let mut ordered_events = self + .ordered_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ordered_events.failure.is_some() + || ordered_events.closed_published + || target_seq <= ordered_events.last_published_seq + { + return Ok(false); + } + for chunk in chunks { + if chunk.seq > ordered_events.last_published_seq { + ordered_events + .pending + .entry(chunk.seq) + .or_insert(ExecProcessEvent::Output(chunk)); + } + } + if closed { + match ordered_events.pending.get(&target_seq) { + Some(ExecProcessEvent::Closed { .. }) => {} + Some(_) => { + return Err(ExecServerError::Protocol(format!( + "process close sequence {target_seq} conflicts with recovered output" + ))); + } + None => { + ordered_events + .pending + .insert(target_seq, ExecProcessEvent::Closed { seq: target_seq }); + } + } + } + + let exit_known = ordered_events.exit_published + || ordered_events + .pending + .range(..=target_seq) + .any(|(_, event)| matches!(event, ExecProcessEvent::Exited { .. })); + let event_count = target_seq - ordered_events.last_published_seq; + let retained_count = ordered_events + .pending + .range(ordered_events.last_published_seq.saturating_add(1)..=target_seq) + .count() as u64; + let missing_count = event_count.saturating_sub(retained_count); + if exited && !exit_known { + if missing_count != 1 { + return Err(recovery_gap_error(target_seq)); + } + let seq = first_missing_seq(&ordered_events, target_seq); + let exit_code = exit_code.ok_or_else(|| { + ExecServerError::Protocol( + "recovering exited process did not include its exit code".to_string(), + ) + })?; + ordered_events + .pending + .insert(seq, ExecProcessEvent::Exited { seq, exit_code }); + } else if missing_count != 0 { + return Err(recovery_gap_error(target_seq)); + } + self.publish_ready(&mut ordered_events) + }; + + self.note_change(target_seq); + Ok(published_closed) + } +} + +fn first_missing_seq(events: &OrderedSessionEvents, target_seq: u64) -> u64 { + let mut expected = events.last_published_seq.saturating_add(1); + for seq in events + .pending + .range(expected..=target_seq) + .map(|(seq, _)| *seq) + { + if seq != expected { + break; + } + expected = expected.saturating_add(1); + } + expected +} + +fn recovery_gap_error(target_seq: u64) -> ExecServerError { + ExecServerError::Protocol(format!( + "process events are no longer retained while recovering through sequence {target_seq}" + )) +} + +impl Inner { + pub(super) async fn rpc_client(self: &Arc) -> Result, ExecServerError> { + let mut connection_changed = self.connection_changed.subscribe(); + loop { + if let Some(message) = self.failure_message() { + return Err(ExecServerError::Disconnected(message)); + } + + let rpc_client = { + let connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Connected(rpc_client) => Some(Arc::clone(rpc_client)), + ConnectionStatus::Recovering | ConnectionStatus::Failed(_) => None, + } + }; + let Some(rpc_client) = rpc_client else { + let _ = connection_changed.changed().await; + continue; + }; + if !rpc_client.is_disconnected() { + return Ok(rpc_client); + } + + let _ = connection_changed.changed().await; + } + } + + pub(super) fn begin_process_start(&self, expected: &Arc) -> bool { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let ConnectionStatus::Connected(current) = &connection.status else { + return false; + }; + if !Arc::ptr_eq(current, expected) || expected.is_disconnected() { + return false; + } + connection.active_process_starts += 1; + true + } + + pub(super) fn finish_process_start(&self) { + { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if connection.active_process_starts == 0 { + tracing::error!("finished an exec-server process start that was not active"); + return; + } + connection.active_process_starts -= 1; + } + self.notify_connection_changed(); + } + + pub(super) fn is_failed(&self) -> bool { + self.failure_message().is_some() + } + + pub(super) fn failure_message(&self) -> Option { + let connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Failed(message) => Some(message.clone()), + ConnectionStatus::Connected(_) | ConnectionStatus::Recovering => None, + } + } + + fn request_recovery( + self: &Arc, + failed_rpc_client: Arc, + disconnect_message: String, + ) { + let should_recover = { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Connected(current) + if Arc::ptr_eq(current, &failed_rpc_client) => + { + connection.status = ConnectionStatus::Recovering; + true + } + ConnectionStatus::Connected(_) + | ConnectionStatus::Recovering + | ConnectionStatus::Failed(_) => false, + } + }; + if !should_recover { + return; + } + + self.notify_connection_changed(); + let inner = Arc::clone(self); + tokio::spawn(async move { + inner.recover(disconnect_message).await; + }); + } + + async fn recover(self: &Arc, disconnect_message: String) { + let deadline = Instant::now() + SESSION_RECOVERY_TIMEOUT; + self.fail_all_http_body_streams(disconnect_message.clone()) + .await; + if timeout_at(deadline, self.wait_for_process_starts()) + .await + .is_err() + { + let message = format!( + "{disconnect_message}; failed to resume exec-server session: recovery timed out after {SESSION_RECOVERY_TIMEOUT:?}" + ); + self.fail(message).await; + return; + } + if self.reconnect_strategy.is_none() { + self.fail(disconnect_message).await; + return; + } + + let Some(session_id) = self.session_id.get().cloned() else { + let message = format!( + "{disconnect_message}; failed to resume exec-server session: missing session id" + ); + self.fail(message).await; + return; + }; + let uses_registry_backoff = matches!( + self.reconnect_strategy.as_ref(), + Some(ExecServerReconnectStrategy::NoiseRendezvous { .. }) + ); + let mut registry_retry_attempt = 0; + let last_error = loop { + match timeout_at(deadline, self.resume_once(&session_id)).await { + Ok(Ok(candidate)) => { + if !candidate.is_disconnected() && self.install_recovered_client(candidate) { + return; + } + } + Ok(Err(error)) if !is_retryable_recovery_error(&error) => { + break error.to_string(); + } + Ok(Err(_)) => {} + Err(_) => { + break format!("recovery timed out after {SESSION_RECOVERY_TIMEOUT:?}"); + } + } + + let retry_delay = if uses_registry_backoff { + let delay = registry_recovery_retry_delay(&session_id, registry_retry_attempt); + registry_retry_attempt = registry_retry_attempt.saturating_add(1); + delay + } else { + SESSION_RECOVERY_RETRY_INTERVAL + }; + + let now = Instant::now(); + if now >= deadline { + break format!("recovery timed out after {SESSION_RECOVERY_TIMEOUT:?}"); + } + sleep(retry_delay.min(deadline - now)).await; + }; + + let message = + format!("{disconnect_message}; failed to resume exec-server session: {last_error}"); + self.fail(message).await; + } + + async fn wait_for_process_starts(&self) { + let mut connection_changed = self.connection_changed.subscribe(); + loop { + let starts_are_done = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active_process_starts + == 0; + if starts_are_done { + return; + } + let _ = connection_changed.changed().await; + } + } + + fn install_recovered_client(&self, rpc_client: Arc) -> bool { + let installed = { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(connection.status, ConnectionStatus::Recovering) + || rpc_client.is_disconnected() + { + false + } else { + connection.status = ConnectionStatus::Connected(rpc_client); + true + } + }; + if installed { + self.notify_connection_changed(); + } + installed + } + + fn notify_connection_changed(&self) { + self.connection_changed.send_replace(()); + } + + async fn resume_once( + self: &Arc, + session_id: &str, + ) -> Result, ExecServerError> { + let reconnect_strategy = self + .reconnect_strategy + .as_ref() + .ok_or_else(|| ExecServerError::Protocol("missing reconnect strategy".to_string()))?; + let (connection, options) = reconnect_strategy.resume(session_id).await?; + let (rpc_client, events_rx) = RpcClient::new(connection); + let rpc_client = Arc::new(rpc_client); + let client = ExecServerClient { + inner: Arc::clone(self), + }; + // Resuming a session redirects notifications from its running processes + // to this connection during initialize. Drain them immediately so a + // burst cannot fill the bounded event channel and block the initialize + // response behind it. + client.spawn_rpc_reader(&rpc_client, events_rx); + client.initialize_rpc(&rpc_client, options).await?; + + self.recover_processes(&rpc_client).await?; + Ok(rpc_client) + } + + async fn recover_processes( + self: &Arc, + rpc_client: &RpcClient, + ) -> Result<(), ExecServerError> { + let sessions = self.sessions.load_full(); + for (process_id, session) in sessions.iter() { + if !session.recoverable.load(Ordering::Acquire) { + continue; + } + let response = rpc_client + .call::<_, ReadResponse>( + EXEC_READ_METHOD, + &ReadParams { + process_id: process_id.clone(), + after_seq: Some(session.last_published_seq()), + max_bytes: None, + wait_ms: Some(0), + }, + ) + .await + .map_err(ExecServerError::from); + let recovered = match response { + Ok(response) => session.recover_events(response), + Err(error) if is_transport_closed_error(&error) => return Err(error), + Err(error) => Err(error), + }; + match recovered { + Ok(true) => self.remove_session_if(process_id, session), + Ok(false) => {} + Err(error) => { + let terminated: Result = rpc_client + .call( + EXEC_TERMINATE_METHOD, + &TerminateParams { + process_id: process_id.clone(), + }, + ) + .await + .map_err(ExecServerError::from); + if let Err(terminate_error) = terminated + && is_transport_closed_error(&terminate_error) + { + return Err(terminate_error); + } + self.remove_session_if(process_id, session); + session.set_failure(format!("failed to recover process {process_id}: {error}")); + } + } + } + Ok(()) + } + + async fn fail(self: &Arc, message: String) { + let (message, newly_failed) = { + let mut connection = self + .connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &connection.status { + ConnectionStatus::Failed(existing) => (existing.clone(), false), + ConnectionStatus::Connected(_) | ConnectionStatus::Recovering => { + connection.status = ConnectionStatus::Failed(message.clone()); + (message, true) + } + } + }; + if newly_failed { + self.notify_connection_changed(); + fail_all_in_flight_work(self, message.clone()).await; + } + } +} + +impl ExecServerClient { + pub(super) fn spawn_rpc_reader( + &self, + rpc_client: &Arc, + mut events_rx: mpsc::Receiver, + ) { + let inner = Arc::downgrade(&self.inner); + let rpc_client = Arc::downgrade(rpc_client); + tokio::spawn(async move { + while let Some(event) = events_rx.recv().await { + let (Some(inner), Some(rpc_client)) = (inner.upgrade(), rpc_client.upgrade()) + else { + return; + }; + match event { + RpcClientEvent::Notification(notification) => { + if let Err(error) = handle_server_notification(&inner, notification).await { + rpc_client.close_transport().await; + inner.request_recovery( + rpc_client, + format!("exec-server notification handling failed: {error}"), + ); + return; + } + } + RpcClientEvent::Disconnected { reason } => { + inner.request_recovery(rpc_client, disconnected_message(reason.as_deref())); + return; + } + } + } + }); + } +} + +fn is_retryable_recovery_error(error: &ExecServerError) -> bool { + is_transport_closed_error(error) + || matches!( + error, + ExecServerError::WebSocketConnectTimeout { .. } + | ExecServerError::WebSocketConnect { .. } + | ExecServerError::InitializeTimedOut { .. } + ) + || is_retryable_registry_error(error) + || matches!( + error, + ExecServerError::Server { code, .. } + if *code == SESSION_ALREADY_ATTACHED_ERROR_CODE + ) +} + +fn is_retryable_registry_error(error: &ExecServerError) -> bool { + matches!( + error, + ExecServerError::EnvironmentRegistryRequest(error) + if error.is_connect() || error.is_timeout() + ) || matches!( + error, + ExecServerError::EnvironmentRegistryHttp { status, .. } + if status.is_server_error() + || *status == reqwest::StatusCode::REQUEST_TIMEOUT + || *status == reqwest::StatusCode::TOO_MANY_REQUESTS + ) +} + +fn registry_recovery_retry_delay(session_id: &str, attempt: u32) -> Duration { + let multiplier = 1_u32.checked_shl(attempt.min(4)).unwrap_or(u32::MAX); + let base_delay = REGISTRY_RECOVERY_INITIAL_RETRY_INTERVAL + .saturating_mul(multiplier) + .min(REGISTRY_RECOVERY_MAX_RETRY_INTERVAL); + let base_millis = base_delay.as_millis() as u64; + let mut hasher = DefaultHasher::new(); + session_id.hash(&mut hasher); + attempt.hash(&mut hasher); + + Duration::from_millis(base_millis + hasher.finish() % (base_millis / 2 + 1)) +} + +#[cfg(test)] +#[path = "client_recovery_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/client_recovery_tests.rs b/codex-rs/exec-server/src/client_recovery_tests.rs new file mode 100644 index 000000000000..a6ff91fc9ccb --- /dev/null +++ b/codex-rs/exec-server/src/client_recovery_tests.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +use super::*; + +fn registry_error() -> ExecServerError { + ExecServerError::EnvironmentRegistryHttp { + status: reqwest::StatusCode::TOO_MANY_REQUESTS, + code: None, + message: "registry unavailable".to_string(), + } +} + +#[test] +fn registry_recovery_retry_delay_exponentially_backs_off_and_caps() { + let cases = [ + (0, Duration::from_millis(500)), + (1, Duration::from_secs(1)), + (2, Duration::from_secs(2)), + (3, Duration::from_secs(4)), + (4, Duration::from_secs(5)), + (20, Duration::from_secs(5)), + ]; + + for (attempt, base) in cases { + let delay = registry_recovery_retry_delay("session-1", attempt); + assert!(delay >= base, "delay {delay:?} for attempt {attempt}"); + assert!( + delay <= base + base / 2, + "delay {delay:?} for attempt {attempt}" + ); + } +} + +#[test] +fn recovery_retries_transient_registry_errors() { + let error = registry_error(); + + assert!(is_retryable_registry_error(&error)); + assert!(is_retryable_recovery_error(&error)); +} diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index c31a8fcdf642..c6cff4ed3063 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -1,4 +1,7 @@ use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Command; @@ -14,12 +17,15 @@ use crate::ExecServerClient; use crate::ExecServerError; use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT; +use crate::client_api::ExecServerClientConnectOptions; use crate::client_api::NoiseRendezvousConnectArgs; use crate::client_api::NoiseRendezvousConnectBundle; +use crate::client_api::NoiseRendezvousConnectProvider; use crate::client_api::RemoteExecServerConnectArgs; use crate::client_api::StdioExecServerCommand; use crate::client_api::StdioExecServerConnectArgs; use crate::connection::JsonRpcConnection; +use crate::noise_channel::NoiseChannelIdentity; use crate::noise_relay::NoiseHarnessConnectionArgs; use crate::noise_relay::noise_harness_connection_from_websocket; use crate::noise_relay::noise_relay_websocket_config; @@ -27,6 +33,57 @@ use crate::relay::harness_connection_from_websocket; const ENVIRONMENT_CLIENT_NAME: &str = "codex-environment"; +/// Reopens the transport for one logical exec-server client session. +/// +/// URL connections reuse their configured endpoint. Noise connections retain +/// the harness identity but fetch a fresh single-use authorization bundle for +/// every physical connection attempt. +#[derive(Clone)] +pub(crate) enum ExecServerReconnectStrategy { + WebSocket(RemoteExecServerConnectArgs), + NoiseRendezvous { + provider: Arc, + identity: NoiseChannelIdentity, + client_name: String, + connect_timeout: Duration, + initialize_timeout: Duration, + }, +} + +impl ExecServerReconnectStrategy { + pub(crate) async fn resume( + &self, + session_id: &str, + ) -> Result<(JsonRpcConnection, ExecServerClientConnectOptions), ExecServerError> { + match self { + Self::WebSocket(args) => { + let mut args = args.clone(); + args.resume_session_id = Some(session_id.to_string()); + let connection = ExecServerClient::open_websocket_connection(&args).await?; + Ok((connection, args.into())) + } + Self::NoiseRendezvous { + provider, + identity, + client_name, + connect_timeout, + initialize_timeout, + } => { + let bundle = provider.connect_bundle(identity.public_key()).await?; + ExecServerClient::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { + bundle, + harness_identity: identity.clone(), + client_name: client_name.clone(), + connect_timeout: *connect_timeout, + initialize_timeout: *initialize_timeout, + resume_session_id: Some(session_id.to_string()), + }) + .await + } + } + } +} + impl ExecServerClient { /// Open the selected transport and run the common JSON-RPC initialization. /// Noise connection details are fetched here so reconnects get a fresh URL @@ -53,16 +110,16 @@ impl ExecServerClient { provider, identity, } => { - let bundle = provider.connect_bundle(identity.public_key()).await?; - Self::connect_noise_rendezvous(NoiseRendezvousConnectArgs { - bundle, - harness_identity: identity, + let reconnect_strategy = ExecServerReconnectStrategy::NoiseRendezvous { + provider: Arc::clone(&provider), + identity: identity.clone(), client_name: ENVIRONMENT_CLIENT_NAME.to_string(), connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, - resume_session_id: None, - }) - .await + }; + let (connection, options) = + Self::open_initial_noise_rendezvous_connection(&provider, &identity).await?; + Self::connect_with_recovery(connection, options, Some(reconnect_strategy)).await } crate::client_api::ExecServerTransportParams::StdioCommand { command, @@ -79,9 +136,56 @@ impl ExecServerClient { } } + async fn open_initial_noise_rendezvous_connection( + provider: &Arc, + identity: &NoiseChannelIdentity, + ) -> Result<(JsonRpcConnection, ExecServerClientConnectOptions), ExecServerError> { + let open_connection = |bundle: NoiseRendezvousConnectBundle| { + Self::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { + bundle, + harness_identity: identity.clone(), + client_name: ENVIRONMENT_CLIENT_NAME.to_string(), + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + resume_session_id: None, + }) + }; + let bundle = provider.connect_bundle(identity.public_key()).await?; + match open_connection(bundle).await { + Err(error) + if matches!( + &error, + ExecServerError::WebSocketConnect { source, .. } + if matches!( + source, + tokio_tungstenite::tungstenite::Error::Http(response) + if response.status().as_u16() == 401 + ) + ) => + { + let bundle = provider.connect_bundle(identity.public_key()).await?; + open_connection(bundle).await + } + result => result, + } + } + pub async fn connect_websocket( args: RemoteExecServerConnectArgs, ) -> Result { + let connection = Self::open_websocket_connection(&args).await?; + let options = args.clone().into(); + Self::connect_with_recovery( + connection, + options, + Some(ExecServerReconnectStrategy::WebSocket(args)), + ) + .await + } + + pub(crate) async fn open_websocket_connection( + args: &RemoteExecServerConnectArgs, + ) -> Result { ensure_rustls_crypto_provider(); let websocket_url = args.websocket_url.clone(); let connect_timeout = args.connect_timeout; @@ -102,15 +206,26 @@ impl ExecServerClient { } else { JsonRpcConnection::from_websocket(stream, connection_label) }; - Self::connect(connection, args.into()).await + Ok(connection) } - /// Connect to one exec-server through an authenticated rendezvous stream. + /// Connect to one exec-server through an authenticated rendezvous stream + /// using a caller-supplied single-use authorization bundle. + /// /// The executor key is pinned before JSON-RPC starts; the websocket carries - /// only ciphertext after that. + /// only ciphertext after that. Environment-managed connections use a + /// retained [`NoiseRendezvousConnectProvider`] so recovery can fetch a fresh + /// bundle for each reconnect. pub async fn connect_noise_rendezvous( args: NoiseRendezvousConnectArgs, ) -> Result { + let (connection, options) = Self::open_noise_rendezvous_connection(args).await?; + Self::connect(connection, options).await + } + + pub(crate) async fn open_noise_rendezvous_connection( + args: NoiseRendezvousConnectArgs, + ) -> Result<(JsonRpcConnection, ExecServerClientConnectOptions), ExecServerError> { ensure_rustls_crypto_provider(); // Keep the registry-issued URL, key, and authorization together for this // connection attempt. @@ -164,15 +279,14 @@ impl ExecServerClient { harness_key_authorization, }, ); - Self::connect( + Ok(( connection, - crate::client_api::ExecServerClientConnectOptions { + ExecServerClientConnectOptions { client_name, initialize_timeout, resume_session_id, }, - ) - .await + )) } pub(crate) async fn connect_stdio_command( @@ -237,3 +351,7 @@ fn stdio_command_process(stdio_command: &StdioExecServerCommand) -> Command { command.process_group(0); command } + +#[cfg(test)] +#[path = "client_transport_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/client_transport_tests.rs b/codex-rs/exec-server/src/client_transport_tests.rs new file mode 100644 index 000000000000..7dab5fa21835 --- /dev/null +++ b/codex-rs/exec-server/src/client_transport_tests.rs @@ -0,0 +1,112 @@ +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Result; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio_tungstenite::accept_async; + +use super::ExecServerClient; +use crate::ExecServerError; +use crate::NoiseChannelIdentity; +use crate::NoiseChannelPublicKey; +use crate::NoiseRendezvousConnectBundle; +use crate::NoiseRendezvousConnectProvider; + +struct SequenceNoiseConnectProvider { + bundles: Mutex>, + returned_urls: Mutex>, +} + +impl SequenceNoiseConnectProvider { + fn new(bundles: Vec) -> Self { + Self { + bundles: Mutex::new(bundles.into()), + returned_urls: Mutex::new(Vec::new()), + } + } + + fn returned_urls(&self) -> Vec { + self.returned_urls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +impl NoiseRendezvousConnectProvider for SequenceNoiseConnectProvider { + fn connect_bundle( + &self, + _: NoiseChannelPublicKey, + ) -> BoxFuture<'_, Result> { + let result = self + .bundles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pop_front() + .ok_or_else(|| ExecServerError::Protocol("test Noise provider exhausted".to_string())); + if let Ok(bundle) = &result { + self.returned_urls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(bundle.websocket_url.clone()); + } + Box::pin(async move { result }) + } +} + +fn test_bundle(websocket_url: String) -> Result { + Ok(NoiseRendezvousConnectBundle { + websocket_url, + environment_id: "environment".to_string(), + executor_registration_id: "registration".to_string(), + executor_public_key: NoiseChannelIdentity::generate()?.public_key(), + harness_key_authorization: "authorization".to_string(), + }) +} + +#[tokio::test] +async fn initial_noise_connection_refreshes_bundle_after_unauthorized_handshake() -> Result<()> { + let unauthorized_listener = TcpListener::bind("127.0.0.1:0").await?; + let unauthorized_url = format!("ws://{}", unauthorized_listener.local_addr()?); + let unauthorized_server = tokio::spawn(async move { + let (mut socket, _) = unauthorized_listener.accept().await?; + let mut request = [0_u8; 4096]; + let _ = socket.read(&mut request).await?; + socket + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await?; + socket.shutdown().await?; + anyhow::Ok(()) + }); + let accepted_listener = TcpListener::bind("127.0.0.1:0").await?; + let accepted_url = format!("ws://{}", accepted_listener.local_addr()?); + let accepted_server = tokio::spawn(async move { + let (socket, _) = accepted_listener.accept().await?; + let _websocket = accept_async(socket).await?; + anyhow::Ok(()) + }); + let sequence = Arc::new(SequenceNoiseConnectProvider::new(vec![ + test_bundle(unauthorized_url.clone())?, + test_bundle(accepted_url.clone())?, + ])); + let provider: Arc = sequence.clone(); + let identity = NoiseChannelIdentity::generate()?; + + let _connection = + ExecServerClient::open_initial_noise_rendezvous_connection(&provider, &identity).await?; + + assert_eq!( + sequence.returned_urls(), + vec![unauthorized_url, accepted_url] + ); + unauthorized_server.await??; + accepted_server.await??; + Ok(()) +} diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 0c703ecef35a..5990d8811dff 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,10 +1,8 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::Mutex; use std::sync::RwLock; -use futures::FutureExt; -use futures::future::BoxFuture; - use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -13,6 +11,7 @@ use crate::NoiseChannelIdentity; use crate::NoiseRendezvousConnectProvider; use crate::client::LazyRemoteExecServerClient; use crate::client::http_client::ReqwestHttpClient; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::ExecServerTransportParams; use crate::environment_provider::DefaultEnvironmentProvider; use crate::environment_provider::EnvironmentDefault; @@ -25,11 +24,20 @@ use crate::local_process::LocalProcess; use crate::process::ExecBackend; use crate::protocol::EnvironmentInfo; use crate::protocol::ShellInfo; +use crate::remote::NoiseRendezvousEnvironmentConfig; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; use codex_shell_command::shell_detect::DetectedShell; +use tokio_util::task::AbortOnDropHandle; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; +pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL"; +pub const CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID"; +pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"; +pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID"; /// Owns the execution/filesystem environments available to the Codex runtime. /// @@ -42,9 +50,9 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// use `default_environment().is_some()` as the signal for model-facing /// shell/filesystem tool availability. /// -/// Remote environments create remote filesystem and execution backends that -/// lazy-connect to the configured exec-server on first use. The remote -/// transport is not opened when the manager or environment is constructed. +/// Remote environments begin connecting when added to the manager. Their +/// filesystem and execution backends share that startup result and reconnect +/// after later disconnects as needed. #[derive(Debug)] pub struct EnvironmentManager { default_environment: Option, @@ -98,6 +106,9 @@ impl EnvironmentManager { codex_home: impl AsRef, local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = environment_provider_from_codex_home(codex_home.as_ref())?; Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -107,6 +118,9 @@ impl EnvironmentManager { pub async fn from_env( local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = DefaultEnvironmentProvider::from_env(); Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -122,6 +136,23 @@ impl EnvironmentManager { } } + fn from_noise_environment_config( + config: NoiseRendezvousEnvironmentConfig, + local_runtime_paths: Option, + ) -> Result { + let manager = Self { + default_environment: Some(REMOTE_ENVIRONMENT_ID.to_string()), + environments: RwLock::new(HashMap::new()), + local_environment: None, + local_runtime_paths, + }; + manager.upsert_noise_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + config.connect_provider(), + )?; + Ok(manager) + } + /// Builds a test-only manager that keeps the provider default while also /// allowing tests to select the local environment explicitly. pub async fn create_for_tests_with_local( @@ -193,6 +224,10 @@ impl EnvironmentManager { Some(environment_id) } }; + // The snapshot is valid; start connecting its remote environments in the background. + for environment in environment_map.values() { + environment.start_connecting(); + } Ok(Self { default_environment, environments: RwLock::new(environment_map), @@ -254,11 +289,13 @@ impl EnvironmentManager { } /// Adds or replaces a named remote environment without changing the - /// manager's default environment selection. + /// manager's default environment selection. Uses the default WebSocket + /// connection timeout when none is provided. pub fn upsert_environment( &self, environment_id: String, exec_server_url: String, + connect_timeout: Option, ) -> Result<(), ExecServerError> { if environment_id.is_empty() { return Err(ExecServerError::Protocol( @@ -276,12 +313,18 @@ impl EnvironmentManager { "remote environment requires an exec-server url".to_string(), )); }; - let environment = - Environment::remote_inner(exec_server_url, self.local_runtime_paths.clone()); + let environment = Arc::new(Environment::remote_with_transport( + ExecServerTransportParams::websocket_url( + exec_server_url, + connect_timeout.unwrap_or(DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT), + ), + self.local_runtime_paths.clone(), + )); + environment.start_connecting(); self.environments .write() .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(environment_id, Arc::new(environment)); + .insert(environment_id, environment); Ok(()) } @@ -305,18 +348,66 @@ impl EnvironmentManager { "failed to generate Noise harness identity: {error}" )) })?; - let environment = Environment::remote_with_transport( + let environment = Arc::new(Environment::remote_with_transport( ExecServerTransportParams::NoiseRendezvous { provider, identity }, self.local_runtime_paths.clone(), - ); + )); + environment.start_connecting(); self.environments .write() .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(environment_id, Arc::new(environment)); + .insert(environment_id, environment); Ok(()) } } +fn noise_environment_config_from_env() +-> Result, ExecServerError> { + noise_environment_config_from_values( + optional_environment_value(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR), + ) +} + +fn noise_environment_config_from_values( + registry_url: Option, + environment_id: Option, + auth_token: Option, + chatgpt_account_id: Option, +) -> Result, ExecServerError> { + let (registry_url, environment_id, auth_token) = + match (registry_url, environment_id, auth_token) { + (None, None, None) => return Ok(None), + (Some(registry_url), Some(environment_id), Some(auth_token)) => { + (registry_url, environment_id, auth_token) + } + _ => { + return Err(ExecServerError::EnvironmentRegistryConfig(format!( + "Noise environment requires {CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR}, \ +{CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR}, and \ +{CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR}" + ))); + } + }; + + let config = NoiseRendezvousEnvironmentConfig::new( + registry_url, + environment_id, + auth_token, + chatgpt_account_id, + )?; + Ok(Some(config)) +} + +fn optional_environment_value(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + /// Concrete execution/filesystem environment selected for a session. /// /// This bundles the selected backend metadata together with the local runtime @@ -324,50 +415,22 @@ impl EnvironmentManager { #[derive(Clone)] pub struct Environment { exec_server_url: Option, - remote_transport: Option, - info_provider: Arc, + remote_client: Option, + // Dropping the environment stops unfinished background startup work. + startup_task: Arc>>>, exec_backend: Arc, filesystem: Arc, http_client: Arc, local_runtime_paths: Option, } -/// Provides environment metadata from either a local environment or a remote exec-server. -trait EnvironmentInfoProvider: Send + Sync { - fn info(&self) -> BoxFuture<'_, Result>; -} - -struct LocalEnvironmentInfoProvider; - -impl EnvironmentInfoProvider for LocalEnvironmentInfoProvider { - fn info(&self) -> BoxFuture<'_, Result> { - std::future::ready(Ok(EnvironmentInfo::local())).boxed() - } -} - -struct RemoteEnvironmentInfoProvider { - client: LazyRemoteExecServerClient, -} - -impl RemoteEnvironmentInfoProvider { - fn new(client: LazyRemoteExecServerClient) -> Self { - Self { client } - } -} - -impl EnvironmentInfoProvider for RemoteEnvironmentInfoProvider { - fn info(&self) -> BoxFuture<'_, Result> { - async move { self.client.environment_info().await }.boxed() - } -} - impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { exec_server_url: None, - remote_transport: None, - info_provider: Arc::new(LocalEnvironmentInfoProvider), + remote_client: None, + startup_task: Arc::new(Mutex::new(None)), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), @@ -423,9 +486,11 @@ impl Environment { pub(crate) fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { exec_server_url: None, - remote_transport: None, - info_provider: Arc::new(LocalEnvironmentInfoProvider), - exec_backend: Arc::new(LocalProcess::default()), + remote_client: None, + startup_task: Arc::new(Mutex::new(None)), + exec_backend: Arc::new(LocalProcess::with_local_runtime_paths( + local_runtime_paths.clone(), + )), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), )), @@ -439,7 +504,10 @@ impl Environment { local_runtime_paths: Option, ) -> Self { Self::remote_with_transport( - ExecServerTransportParams::websocket_url(exec_server_url), + ExecServerTransportParams::websocket_url( + exec_server_url, + DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + ), local_runtime_paths, ) } @@ -456,15 +524,15 @@ impl Environment { ExecServerTransportParams::NoiseRendezvous { .. } => None, ExecServerTransportParams::StdioCommand { .. } => None, }; - let client = LazyRemoteExecServerClient::new(remote_transport.clone()); + let client = LazyRemoteExecServerClient::new(remote_transport); let exec_backend: Arc = Arc::new(RemoteProcess::new(client.clone())); let filesystem: Arc = Arc::new(RemoteFileSystem::new(client.clone())); Self { exec_server_url, - remote_transport: Some(remote_transport), - info_provider: Arc::new(RemoteEnvironmentInfoProvider::new(client.clone())), + remote_client: Some(client.clone()), + startup_task: Arc::new(Mutex::new(None)), exec_backend, filesystem, http_client: Arc::new(client), @@ -473,7 +541,7 @@ impl Environment { } pub fn is_remote(&self) -> bool { - self.remote_transport.is_some() + self.remote_client.is_some() } /// Returns the remote exec-server URL when this environment is remote. @@ -487,7 +555,40 @@ impl Environment { /// Returns environment information from the selected execution/filesystem environment. pub async fn info(&self) -> Result { - self.info_provider.info().await + match &self.remote_client { + Some(client) => client.environment_info().await, + None => Ok(EnvironmentInfo::local()), + } + } + + /// Starts connecting a remote environment without waiting for it. + /// Requires an active Tokio runtime when background startup is supported. + pub fn start_connecting(&self) { + let Some(client) = &self.remote_client else { + return; + }; + let mut startup_task = self + .startup_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if startup_task.is_none() { + *startup_task = client.start_connecting(); + } + } + + /// Returns whether initial startup has either succeeded or permanently failed. + pub fn startup_finished(&self) -> bool { + self.remote_client + .as_ref() + .is_none_or(LazyRemoteExecServerClient::startup_finished) + } + + /// Waits for initial startup. A failed startup is never attempted again. + pub async fn wait_until_ready(&self) -> Result<(), ExecServerError> { + match &self.remote_client { + Some(client) => client.wait_until_ready().await, + None => Ok(()), + } } pub fn get_exec_backend(&self) -> Arc { @@ -522,18 +623,25 @@ impl From for ShellInfo { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::sync::Arc; + use std::time::Duration; use super::Environment; use super::EnvironmentManager; use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; + use super::noise_environment_config_from_values; use crate::ExecServerRuntimePaths; use crate::ProcessId; + use crate::client_api::ExecServerTransportParams; + use crate::client_api::StdioExecServerCommand; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProviderSnapshot; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; + use tokio::net::TcpListener; + use tokio::time::timeout; fn test_runtime_paths() -> ExecServerRuntimePaths { ExecServerRuntimePaths::new( @@ -547,6 +655,35 @@ mod tests { assert!(manager.try_local_environment().is_none()); } + #[tokio::test] + async fn noise_environment_config_selects_remote_as_default() { + let config = noise_environment_config_from_values( + Some("http://registry.example/api".to_string()), + Some("environment-requested".to_string()), + Some("registry-token".to_string()), + Some("workspace-123".to_string()), + ) + .expect("parse noise environment configuration") + .expect("noise environment configuration"); + + let manager = EnvironmentManager::from_noise_environment_config( + config, /*local_runtime_paths*/ None, + ) + .expect("build environment manager"); + + assert_eq!( + manager.default_environment_id(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert!( + manager + .default_environment() + .expect("remote environment") + .is_remote() + ); + assert_local_environment_unavailable(&manager); + } + #[tokio::test] async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) @@ -854,7 +991,11 @@ mod tests { let manager = EnvironmentManager::without_environments(); manager - .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:8765".to_string()) + .upsert_environment( + "executor-a".to_string(), + "ws://127.0.0.1:8765".to_string(), + /*connect_timeout*/ None, + ) .expect("remote environment"); let first = manager .get_environment("executor-a") @@ -864,7 +1005,11 @@ mod tests { assert_eq!(manager.default_environment_id(), None); manager - .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:9876".to_string()) + .upsert_environment( + "executor-a".to_string(), + "ws://127.0.0.1:9876".to_string(), + /*connect_timeout*/ None, + ) .expect("updated remote environment"); let second = manager .get_environment("executor-a") @@ -874,12 +1019,121 @@ mod tests { assert!(!Arc::ptr_eq(&first, &second)); } + #[tokio::test] + async fn environment_manager_starts_remote_environment_when_upserted() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind websocket listener"); + let manager = EnvironmentManager::without_environments(); + + manager + .upsert_environment( + "executor-a".to_string(), + format!("ws://{}", listener.local_addr().expect("listener address")), + /*connect_timeout*/ None, + ) + .expect("remote environment"); + + timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("environment should start connecting when registered") + .expect("accept connection"); + } + + #[tokio::test] + async fn environment_manager_leaves_stdio_environment_lazy() { + let environment = Environment::remote_with_transport( + ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program: "codex-missing-exec-server-for-test".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: None, + }, + initialize_timeout: Duration::from_secs(1), + }, + /*local_runtime_paths*/ None, + ); + let manager = EnvironmentManager::from_snapshot( + EnvironmentProviderSnapshot { + environments: vec![("stdio".to_string(), environment)], + default: EnvironmentDefault::Disabled, + include_local: false, + }, + /*local_runtime_paths*/ None, + ) + .expect("environment manager"); + let environment = manager.get_environment("stdio").expect("stdio environment"); + + assert!(!environment.startup_finished()); + assert!(environment.wait_until_ready().await.is_err()); + assert!(environment.startup_finished()); + } + + #[tokio::test] + async fn replacing_environment_stops_its_startup_task() { + let first_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind first websocket listener"); + let second_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind second websocket listener"); + let manager = EnvironmentManager::without_environments(); + manager + .upsert_environment( + "executor-a".to_string(), + format!( + "ws://{}", + first_listener.local_addr().expect("first listener address") + ), + /*connect_timeout*/ None, + ) + .expect("first remote environment"); + let environment = manager + .get_environment("executor-a") + .expect("first remote environment"); + let startup_abort = environment + .startup_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .expect("startup task") + .abort_handle(); + assert!(!startup_abort.is_finished()); + drop(environment); + + manager + .upsert_environment( + "executor-a".to_string(), + format!( + "ws://{}", + second_listener + .local_addr() + .expect("second listener address") + ), + /*connect_timeout*/ None, + ) + .expect("replacement remote environment"); + + timeout(Duration::from_secs(1), async { + while !startup_abort.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .expect("replacing the environment should cancel its startup task"); + } + #[tokio::test] async fn environment_manager_rejects_empty_remote_environment_url() { let manager = EnvironmentManager::without_environments(); let err = manager - .upsert_environment("executor-a".to_string(), String::new()) + .upsert_environment( + "executor-a".to_string(), + String::new(), + /*connect_timeout*/ None, + ) .expect_err("empty URL should fail"); assert_eq!( @@ -904,6 +1158,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .expect("start process"); @@ -911,6 +1167,50 @@ 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_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/environment_registry.rs b/codex-rs/exec-server/src/environment_registry.rs new file mode 100644 index 000000000000..bc0755fd68b4 --- /dev/null +++ b/codex-rs/exec-server/src/environment_registry.rs @@ -0,0 +1,68 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::NoiseChannelPublicKey; + +/// Request body for registering an executor with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryRegistrationRequest { + pub security_profile: String, + pub executor_public_key: NoiseChannelPublicKey, +} + +/// Environment registry response returned after executor registration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryRegistrationResponse { + pub environment_id: String, + pub url: String, + pub security_profile: String, + pub executor_registration_id: String, +} + +/// Request body for connecting a harness key with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectRequest { + pub harness_public_key: NoiseChannelPublicKey, +} + +/// Environment registry response returned after connecting a harness key. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectResponse { + pub environment_id: String, + pub url: String, + pub security_profile: String, + pub executor_registration_id: String, + pub executor_public_key: NoiseChannelPublicKey, + pub harness_key_authorization: String, +} + +impl std::fmt::Debug for EnvironmentRegistryConnectResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvironmentRegistryConnectResponse") + .field("environment_id", &self.environment_id) + .field("url", &"") + .field("security_profile", &self.security_profile) + .field("executor_registration_id", &self.executor_registration_id) + .field("executor_public_key", &self.executor_public_key) + .field("harness_key_authorization", &"") + .finish() + } +} + +/// Request body for authorizing a harness key with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryHarnessKeyValidationRequest { + pub executor_registration_id: String, + pub harness_public_key: NoiseChannelPublicKey, + pub harness_key_authorization: String, +} + +/// Environment registry response returned after harness key validation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryHarnessKeyValidationResponse { + pub valid: bool, +} + +#[cfg(test)] +#[path = "environment_registry_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/environment_registry_tests.rs b/codex-rs/exec-server/src/environment_registry_tests.rs new file mode 100644 index 000000000000..8c32e0fb45af --- /dev/null +++ b/codex-rs/exec-server/src/environment_registry_tests.rs @@ -0,0 +1,22 @@ +use crate::EnvironmentRegistryConnectResponse; +use crate::NoiseChannelIdentity; + +#[test] +fn connect_response_debug_redacts_authorizations() { + let response = EnvironmentRegistryConnectResponse { + environment_id: "environment-1".to_string(), + url: "wss://rendezvous.test?sig=secret-url-authorization".to_string(), + security_profile: "noise_hybrid_ik_v1".to_string(), + executor_registration_id: "registration-1".to_string(), + executor_public_key: NoiseChannelIdentity::generate() + .expect("identity") + .public_key(), + harness_key_authorization: "secret-harness-authorization".to_string(), + }; + + let debug = format!("{response:?}"); + + assert!(debug.contains("")); + assert!(!debug.contains("secret-url-authorization")); + assert!(!debug.contains("secret-harness-authorization")); +} diff --git a/codex-rs/exec-server/src/file_read.rs b/codex-rs/exec-server/src/file_read.rs new file mode 100644 index 000000000000..8cc84b67300d --- /dev/null +++ b/codex-rs/exec-server/src/file_read.rs @@ -0,0 +1,128 @@ +use std::collections::HashMap; +use std::fs::File; +use std::io; +use std::sync::Arc; + +use codex_file_system::FILE_READ_CHUNK_SIZE; +use tokio::sync::Mutex; + +const MAX_OPEN_FILE_READS: usize = 128; + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct FileReadBlock { + pub(crate) bytes: Vec, + pub(crate) eof: bool, +} + +#[derive(Clone, Default)] +pub(crate) struct FileReadHandleManager { + handles: Arc>>>, +} + +impl FileReadHandleManager { + pub(crate) async fn open( + &self, + handle_id: String, + file: tokio::fs::File, + ) -> io::Result { + let file = Arc::new(file.into_std().await); + let mut handles = self.handles.lock().await; + if handles.contains_key(&handle_id) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("file read handle `{handle_id}` already exists"), + )); + } + if handles.len() >= MAX_OPEN_FILE_READS { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("at most {MAX_OPEN_FILE_READS} file reads may be open per connection"), + )); + } + handles.insert(handle_id.clone(), file); + Ok(handle_id) + } + + pub(crate) async fn read_block( + &self, + handle_id: &str, + offset: u64, + len: usize, + ) -> io::Result { + validate_read_block_len(len)?; + let file = { + let handles = self.handles.lock().await; + handles + .get(handle_id) + .cloned() + .ok_or_else(|| unknown_handle_error(handle_id))? + }; + let result = + match tokio::task::spawn_blocking(move || read_block_at(&file, offset, len)).await { + Ok(result) => result, + Err(error) => Err(io::Error::other(format!( + "file read task stopped unexpectedly: {error}" + ))), + }; + if result.is_err() { + self.close(handle_id).await; + } + result + } + + pub(crate) async fn close(&self, handle_id: &str) { + self.handles.lock().await.remove(handle_id); + } + + pub(crate) async fn close_all(&self) { + self.handles.lock().await.clear(); + } +} + +fn read_block_at(file: &File, offset: u64, len: usize) -> io::Result { + let mut bytes = vec![0; len]; + let mut bytes_read = 0; + while bytes_read < len { + let read_offset = offset.checked_add(bytes_read as u64).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "file read offset overflowed") + })?; + match read_file_at(file, &mut bytes[bytes_read..], read_offset) { + Ok(0) => break, + Ok(read) => bytes_read += read, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + bytes.truncate(bytes_read); + Ok(FileReadBlock { + eof: bytes_read < len, + bytes, + }) +} + +#[cfg(unix)] +fn read_file_at(file: &File, bytes: &mut [u8], offset: u64) -> io::Result { + std::os::unix::fs::FileExt::read_at(file, bytes, offset) +} + +#[cfg(windows)] +fn read_file_at(file: &File, bytes: &mut [u8], offset: u64) -> io::Result { + std::os::windows::fs::FileExt::seek_read(file, bytes, offset) +} + +fn validate_read_block_len(len: usize) -> io::Result<()> { + if !(1..=FILE_READ_CHUNK_SIZE).contains(&len) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("file read block length must be between 1 and {FILE_READ_CHUNK_SIZE}"), + )); + } + Ok(()) +} + +fn unknown_handle_error(handle_id: &str) -> io::Error { + io::Error::new( + io::ErrorKind::NotFound, + format!("unknown file read handle `{handle_id}`"), + ) +} diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index cd2cc38cb3b9..3dbcf73c1461 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -9,6 +9,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxDirectSpawnTransformRequest; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; @@ -88,7 +89,7 @@ impl FileSystemSandboxRunner { &file_system_policy, network_policy, ); - let command = self.sandbox_exec_request(&permission_profile, &cwd.uri, sandbox)?; + let command = self.sandbox_exec_request(&permission_profile, &cwd, sandbox)?; let request_json = serde_json::to_vec(&request).map_err(json_error)?; run_command(command, request_json).await } @@ -96,7 +97,7 @@ impl FileSystemSandboxRunner { fn sandbox_exec_request( &self, permission_profile: &PermissionProfile, - cwd: &PathUri, + cwd: &SandboxCwd, sandbox_context: &FileSystemSandboxContext, ) -> Result { let helper = &self.runtime_paths.codex_self_exe; @@ -112,22 +113,37 @@ impl FileSystemSandboxRunner { let command = SandboxCommand { program: helper.as_path().as_os_str().to_owned(), args: vec![CODEX_FS_HELPER_ARG1.to_string()], - cwd: cwd.clone(), + cwd: cwd.uri.clone(), env: self.helper_env.clone(), additional_permissions: None, }; + let native_workspace_roots = sandbox_context + .workspace_roots + .iter() + .map(native_workspace_root) + .collect::, _>>()?; + let workspace_roots = if native_workspace_roots.is_empty() { + std::slice::from_ref(&cwd.native) + } else { + native_workspace_roots.as_slice() + }; sandbox_manager - .transform(SandboxTransformRequest { - command, - permissions: permission_profile, - sandbox, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd, - codex_linux_sandbox_exe: self.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, + .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { + workspace_roots, + transform: SandboxTransformRequest { + command, + permissions: permission_profile, + sandbox, + enforce_managed_network: false, + environment_id: None, + network: None, + sandbox_policy_cwd: &cwd.uri, + codex_linux_sandbox_exe: self.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_request(format!("failed to prepare fs sandbox: {err}"))) } @@ -154,9 +170,14 @@ fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result Result { - cwd.to_abs_path().map_err(|err| { + cwd.to_abs_path() + .map_err(|err| invalid_request(err.to_string())) +} + +fn native_workspace_root(root: &PathUri) -> Result { + root.to_abs_path().map_err(|err| { invalid_request(format!( - "file system sandbox cwd is not native to this exec-server host: {err}" + "file system sandbox workspace root is not native to this exec-server host: {err}" )) }) } @@ -321,6 +342,8 @@ fn spawn_command( #[cfg(not(unix))] let _ = arg0; command.args(args); + // TODO(anp): Keep PathUri through the filesystem helper launch boundary. + let cwd = cwd.to_abs_path().map_err(io_error)?; command.current_dir(cwd.as_path()); command.env_clear(); command.envs(env); @@ -520,15 +543,21 @@ mod tests { let runner = FileSystemSandboxRunner::new(runtime_paths); let native_cwd = AbsolutePathBuf::current_dir().expect("cwd"); let cwd = PathUri::from_abs_path(&native_cwd); - let file_system_policy = - restricted_policy(vec![path_entry(native_cwd, FileSystemAccessMode::Write)]); + let file_system_policy = restricted_policy(vec![path_entry( + native_cwd.clone(), + FileSystemAccessMode::Write, + )]); let network_policy = NetworkSandboxPolicy::Restricted; let permission_profile = PermissionProfile::from_runtime_permissions(&file_system_policy, network_policy); let sandbox_context = sandbox_context_with_cwd(&file_system_policy, cwd.clone()); + let sandbox_cwd = SandboxCwd { + uri: cwd, + native: native_cwd, + }; let request = runner - .sandbox_exec_request(&permission_profile, &cwd, &sandbox_context) + .sandbox_exec_request(&permission_profile, &sandbox_cwd, &sandbox_context) .expect("sandbox exec request"); assert_eq!(request.env.get(&path_key), Some(&path)); @@ -561,16 +590,16 @@ mod tests { FileSystemSpecialPath::project_roots(/*subpath*/ None), FileSystemAccessMode::Write, )]); - let sandbox_context = sandbox_context_with_cwd(&policy, cwd); + let sandbox_context = sandbox_context_with_cwd(&policy, cwd.clone()); let err = sandbox_cwd(&sandbox_context).expect_err("non-native cwd should be rejected"); assert_eq!( err, - crate::rpc::invalid_request( - "file system sandbox cwd is not native to this exec-server host: file URI contains an invalid absolute path" - .to_string() - ) + crate::rpc::invalid_request(format!( + "'{cwd}' is invalid on '{}'", + std::env::consts::OS + )) ); } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 8c7aff8a5710..d5c71674acd1 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -4,7 +4,9 @@ mod client_transport; mod connection; mod environment; mod environment_provider; +mod environment_registry; mod environment_toml; +mod file_read; mod fs_helper; mod fs_helper_main; mod fs_sandbox; @@ -14,7 +16,9 @@ mod noise_channel; mod noise_relay; mod process; mod process_id; +mod process_sandbox; mod protocol; +mod regular_file; mod relay; mod relay_proto; mod remote; @@ -39,7 +43,9 @@ pub use codex_file_system::CopyOptions; pub use codex_file_system::CreateDirectoryOptions; pub use codex_file_system::ExecutorFileSystem; pub use codex_file_system::ExecutorFileSystemFuture; +pub use codex_file_system::FILE_READ_CHUNK_SIZE; pub use codex_file_system::FileMetadata; +pub use codex_file_system::FileSystemReadStream; pub use codex_file_system::FileSystemResult; pub use codex_file_system::FileSystemSandboxContext; pub use codex_file_system::ReadDirectoryEntry; @@ -52,6 +58,12 @@ pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment_provider::DefaultEnvironmentProvider; pub use environment_provider::EnvironmentProvider; pub use environment_provider::EnvironmentProviderFuture; +pub use environment_registry::EnvironmentRegistryConnectRequest; +pub use environment_registry::EnvironmentRegistryConnectResponse; +pub use environment_registry::EnvironmentRegistryHarnessKeyValidationRequest; +pub use environment_registry::EnvironmentRegistryHarnessKeyValidationResponse; +pub use environment_registry::EnvironmentRegistryRegistrationRequest; +pub use environment_registry::EnvironmentRegistryRegistrationResponse; pub use fs_helper::CODEX_FS_HELPER_ARG1; pub use fs_helper_main::main as run_fs_helper_main; pub use local_file_system::LOCAL_FS; @@ -67,6 +79,7 @@ pub use process::ExecProcessEventReceiver; pub use process::ExecProcessFuture; pub use process::StartedExecProcess; pub use process_id::ProcessId; +pub use protocol::ByteChunk; pub use protocol::EnvironmentInfo; pub use protocol::ExecClosedNotification; pub use protocol::ExecEnvPolicy; @@ -77,12 +90,18 @@ pub use protocol::ExecParams; pub use protocol::ExecResponse; pub use protocol::FsCanonicalizeParams; pub use protocol::FsCanonicalizeResponse; +pub use protocol::FsCloseParams; +pub use protocol::FsCloseResponse; pub use protocol::FsCopyParams; pub use protocol::FsCopyResponse; pub use protocol::FsCreateDirectoryParams; pub use protocol::FsCreateDirectoryResponse; pub use protocol::FsGetMetadataParams; pub use protocol::FsGetMetadataResponse; +pub use protocol::FsOpenParams; +pub use protocol::FsOpenResponse; +pub use protocol::FsReadBlockParams; +pub use protocol::FsReadBlockResponse; pub use protocol::FsReadDirectoryEntry; pub use protocol::FsReadDirectoryParams; pub use protocol::FsReadDirectoryResponse; diff --git a/codex-rs/exec-server/src/local_file_system.rs b/codex-rs/exec-server/src/local_file_system.rs index 8b578a390420..91594d71bdfb 100644 --- a/codex-rs/exec-server/src/local_file_system.rs +++ b/codex-rs/exec-server/src/local_file_system.rs @@ -7,21 +7,33 @@ use std::sync::LazyLock; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tokio::io; +use tokio::io::AsyncReadExt; +use tokio_util::io::ReaderStream; use crate::CopyOptions; use crate::CreateDirectoryOptions; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; use crate::ExecutorFileSystemFuture; +use crate::FILE_READ_CHUNK_SIZE; use crate::FileMetadata; +use crate::FileSystemReadStream; use crate::FileSystemResult; use crate::FileSystemSandboxContext; use crate::ReadDirectoryEntry; use crate::RemoveOptions; +use crate::regular_file; use crate::sandboxed_file_system::SandboxedFileSystem; const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024; +fn file_too_large_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("file is too large to read: limit is {MAX_READ_FILE_BYTES} bytes"), + ) +} + pub static LOCAL_FS: LazyLock> = LazyLock::new(|| -> Arc { Arc::new(LocalFileSystem::unsandboxed()) }); @@ -79,6 +91,20 @@ impl LocalFileSystem { } impl LocalFileSystem { + pub(crate) async fn open_file_for_read( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "streaming file reads do not support platform sandboxing", + )); + } + self.unsandboxed.open_file_for_read(path, sandbox).await + } + async fn canonicalize( &self, path: &PathUri, @@ -97,6 +123,15 @@ impl LocalFileSystem { file_system.read_file(path, sandbox).await } + async fn read_file_stream( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + let (file_system, sandbox) = self.file_system_for(sandbox)?; + file_system.read_file_stream(path, sandbox).await + } + async fn write_file( &self, path: &PathUri, @@ -176,6 +211,14 @@ impl ExecutorFileSystem for LocalFileSystem { Box::pin(LocalFileSystem::read_file(self, path, sandbox)) } + fn read_file_stream<'a>( + &'a self, + path: &'a PathUri, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(LocalFileSystem::read_file_stream(self, path, sandbox)) + } + fn write_file<'a>( &'a self, path: &'a PathUri, @@ -239,6 +282,17 @@ impl ExecutorFileSystem for LocalFileSystem { } impl UnsandboxedFileSystem { + async fn open_file_for_read( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + reject_platform_sandbox_context(sandbox)?; + self.file_system + .open_file_for_read(path, /*sandbox*/ None) + .await + } + async fn canonicalize( &self, path: &PathUri, @@ -257,6 +311,17 @@ impl UnsandboxedFileSystem { self.file_system.read_file(path, /*sandbox*/ None).await } + async fn read_file_stream( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + reject_platform_sandbox_context(sandbox)?; + self.file_system + .read_file_stream(path, /*sandbox*/ None) + .await + } + async fn write_file( &self, path: &PathUri, @@ -349,6 +414,14 @@ impl ExecutorFileSystem for UnsandboxedFileSystem { Box::pin(UnsandboxedFileSystem::read_file(self, path, sandbox)) } + fn read_file_stream<'a>( + &'a self, + path: &'a PathUri, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(UnsandboxedFileSystem::read_file_stream(self, path, sandbox)) + } + fn write_file<'a>( &'a self, path: &'a PathUri, @@ -414,6 +487,16 @@ impl ExecutorFileSystem for UnsandboxedFileSystem { } impl DirectFileSystem { + async fn open_file_for_read( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + reject_sandbox_context(sandbox)?; + let path = path.to_abs_path()?; + regular_file::open(path.as_path()).await + } + async fn canonicalize( &self, path: &PathUri, @@ -431,16 +514,31 @@ impl DirectFileSystem { path: &PathUri, sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult> { - reject_sandbox_context(sandbox)?; - let path = path.to_abs_path()?; - let metadata = tokio::fs::metadata(path.as_path()).await?; + let file = self.open_file_for_read(path, sandbox).await?; + let metadata = file.metadata().await?; if metadata.len() > MAX_READ_FILE_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("file is too large to read: limit is {MAX_READ_FILE_BYTES} bytes"), - )); + return Err(file_too_large_error()); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_READ_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .await?; + if bytes.len() as u64 > MAX_READ_FILE_BYTES { + return Err(file_too_large_error()); } - tokio::fs::read(path.as_path()).await + Ok(bytes) + } + + async fn read_file_stream( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + let file = self.open_file_for_read(path, sandbox).await?; + Ok(FileSystemReadStream::new(ReaderStream::with_capacity( + file, + FILE_READ_CHUNK_SIZE, + ))) } async fn write_file( @@ -619,6 +717,14 @@ impl ExecutorFileSystem for DirectFileSystem { Box::pin(DirectFileSystem::read_file(self, path, sandbox)) } + fn read_file_stream<'a>( + &'a self, + path: &'a PathUri, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(DirectFileSystem::read_file_stream(self, path, sandbox)) + } + fn write_file<'a>( &'a self, path: &'a PathUri, diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 78a53897ee48..c7e99de53c5e 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -1,13 +1,20 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::collections::hash_map::Entry; use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; 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; @@ -23,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; @@ -54,6 +63,8 @@ use crate::rpc::invalid_request; const RETAINED_OUTPUT_BYTES_PER_PROCESS: usize = 1024 * 1024; const NOTIFICATION_CHANNEL_CAPACITY: usize = 256; const PROCESS_EVENT_CHANNEL_CAPACITY: usize = 256; +const RETAINED_STDIN_WRITE_IDS_PER_PROCESS: usize = 4096; +static NEXT_LOCAL_STDIN_WRITE_ID: AtomicU64 = AtomicU64::new(1); #[cfg(test)] const EXITED_PROCESS_RETENTION: Duration = Duration::from_millis(25); #[cfg(not(test))] @@ -70,6 +81,7 @@ struct RunningProcess { session: ExecCommandSession, tty: bool, pipe_stdin: bool, + accepted_stdin_write_ids: Arc>, output: VecDeque, retained_bytes: usize, next_seq: u64, @@ -79,10 +91,45 @@ 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. +/// +/// A remote client can retry `process/write` after reconnecting. Remembering accepted +/// ids lets the server acknowledge the retried request without writing the same bytes +/// to child stdin twice. +#[derive(Default)] +struct AcceptedStdinWriteIds { + ids: HashSet, + order: VecDeque, +} + +impl AcceptedStdinWriteIds { + fn contains(&self, write_id: &str) -> bool { + self.ids.contains(write_id) + } + + fn remember(&mut self, write_id: String) { + if !self.ids.insert(write_id.clone()) { + return; + } + + self.order.push_back(write_id); + while self.order.len() > RETAINED_STDIN_WRITE_IDS_PER_PROCESS { + let Some(evicted) = self.order.pop_front() else { + break; + }; + self.ids.remove(&evicted); + } + } +} + +struct ProcessStart; + enum ProcessEntry { - Starting, + Starting(Arc), Running(Box), } @@ -94,6 +141,7 @@ struct Inner { #[derive(Clone)] pub(crate) struct LocalProcess { inner: Arc, + runtime_paths: Option, } struct LocalExecProcess { @@ -105,20 +153,39 @@ struct LocalExecProcess { impl Default for LocalProcess { fn default() -> Self { + Self::with_discarded_notifications(/*runtime_paths*/ None) + } +} + +impl LocalProcess { + 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::new(RpcNotificationSender::new(outgoing_tx)) + Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), runtime_paths) } -} -impl LocalProcess { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + 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, } } @@ -128,7 +195,7 @@ impl LocalProcess { processes .drain() .filter_map(|(_, process)| match process { - ProcessEntry::Starting => None, + ProcessEntry::Starting(_) => None, ProcessEntry::Running(process) => Some(process), }) .collect::>() @@ -152,17 +219,14 @@ 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); { let mut process_map = self.inner.processes.lock().await; if process_map.contains_key(&process_id) { @@ -170,17 +234,19 @@ impl LocalProcess { "process {process_id} already exists" ))); } - process_map.insert(process_id.clone(), ProcessEntry::Starting); + process_map.insert( + process_id.clone(), + ProcessEntry::Starting(Arc::clone(&start)), + ); } - 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 @@ -188,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 }; @@ -207,7 +273,10 @@ impl LocalProcess { Ok(spawned) => spawned, Err(err) => { let mut process_map = self.inner.processes.lock().await; - if matches!(process_map.get(&process_id), Some(ProcessEntry::Starting)) { + if matches!( + process_map.get(&process_id), + Some(ProcessEntry::Starting(current)) if Arc::ptr_eq(current, &start) + ) { process_map.remove(&process_id); } return Err(internal_error(err.to_string())); @@ -222,12 +291,25 @@ impl LocalProcess { ); { let mut process_map = self.inner.processes.lock().await; + if !matches!( + process_map.get(&process_id), + Some(ProcessEntry::Starting(current)) if Arc::ptr_eq(current, &start) + ) { + drop(process_map); + spawned.session.terminate(); + return Err(invalid_request(format!( + "process {process_id} start was cancelled" + ))); + } process_map.insert( process_id.clone(), ProcessEntry::Running(Box::new(RunningProcess { session: spawned.session, tty: params.tty, pipe_stdin: params.pipe_stdin, + accepted_stdin_write_ids: Arc::new( + Mutex::new(AcceptedStdinWriteIds::default()), + ), output: VecDeque::new(), retained_bytes: 0, next_seq: 1, @@ -237,6 +319,8 @@ impl LocalProcess { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: prepared.sandbox, + sandbox_denied: false, })), ); } @@ -320,7 +404,9 @@ impl LocalProcess { break; } } - + if params.max_bytes.is_none() { + next_seq = process.next_seq; + } ( ReadResponse { chunks, @@ -329,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), ) @@ -362,7 +449,11 @@ impl LocalProcess { params: WriteParams, ) -> Result { let _input_bytes = params.chunk.0.len(); - let writer_tx = { + if params.write_id.is_empty() { + return Err(invalid_params("writeId must not be empty".to_string())); + } + + let (writer_tx, accepted_stdin_write_ids) = { let process_map = self.inner.processes.lock().await; let Some(process) = process_map.get(¶ms.process_id) else { return Ok(WriteResponse { @@ -379,13 +470,37 @@ impl LocalProcess { status: WriteStatus::StdinClosed, }); } - process.session.writer_sender() + ( + process.session.writer_sender(), + Arc::clone(&process.accepted_stdin_write_ids), + ) }; - writer_tx - .send(params.chunk.into_inner()) + if accepted_stdin_write_ids + .lock() + .await + .contains(¶ms.write_id) + { + return Ok(WriteResponse { + status: WriteStatus::Accepted, + }); + } + + let permit = writer_tx + .reserve() .await .map_err(|_| internal_error("failed to write to process stdin".to_string()))?; + let mut accepted_stdin_write_ids = accepted_stdin_write_ids.lock().await; + if accepted_stdin_write_ids.contains(¶ms.write_id) { + return Ok(WriteResponse { + status: WriteStatus::Accepted, + }); + } + + // After this synchronous send, record the write id before any further await. + // Otherwise a cancelled RPC handler could retry and write the same bytes again. + permit.send(params.chunk.into_inner()); + accepted_stdin_write_ids.remember(params.write_id); Ok(WriteResponse { status: WriteStatus::Accepted, @@ -408,7 +523,7 @@ impl LocalProcess { .signal(pty_process_signal(params.signal)) .map_err(|err| internal_error(format!("failed to signal process: {err}")))? } - Some(ProcessEntry::Starting) | None => {} + Some(ProcessEntry::Starting(_)) | None => {} } } @@ -420,7 +535,7 @@ impl LocalProcess { params: TerminateParams, ) -> Result { let running = { - let process_map = self.inner.processes.lock().await; + let mut process_map = self.inner.processes.lock().await; match process_map.get(¶ms.process_id) { Some(ProcessEntry::Running(process)) => { if process.exit_code.is_some() { @@ -429,7 +544,11 @@ impl LocalProcess { process.session.terminate(); true } - Some(ProcessEntry::Starting) | None => false, + Some(ProcessEntry::Starting(_)) => { + process_map.remove(¶ms.process_id); + true + } + None => false, } }; @@ -576,6 +695,10 @@ impl LocalProcess { self.exec_write(WriteParams { process_id: process_id.clone(), chunk: chunk.into(), + write_id: format!( + "local-{}", + NEXT_LOCAL_STDIN_WRITE_ID.fetch_add(1, Ordering::Relaxed) + ), }) .await .map_err(map_handler_error) @@ -683,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 @@ -805,6 +962,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } @@ -884,6 +1043,7 @@ mod tests { exit_code: Some(0), closed: false, failure: None, + sandbox_denied: false, } ); @@ -915,6 +1075,16 @@ mod tests { ) .await .expect("process should close"); + let replay_after_exit = backend + .exec_read(ReadParams { + process_id: process.process_id.clone(), + after_seq: Some(1), + max_bytes: None, + wait_ms: Some(0), + }) + .await + .expect("closed process should remain readable"); + assert_eq!(replay_after_exit.next_seq, 4); backend.shutdown().await; } @@ -988,6 +1158,7 @@ mod tests { session: dummy_session(), tty: false, pipe_stdin: false, + accepted_stdin_write_ids: Arc::new(Mutex::new(AcceptedStdinWriteIds::default())), output: VecDeque::new(), retained_bytes: 0, next_seq: 1, @@ -997,6 +1168,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/noise_relay/harness.rs b/codex-rs/exec-server/src/noise_relay/harness.rs index ef75f252638b..1989525224f6 100644 --- a/codex-rs/exec-server/src/noise_relay/harness.rs +++ b/codex-rs/exec-server/src/noise_relay/harness.rs @@ -86,12 +86,10 @@ where let (outgoing_tx, mut outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); let (disconnected_tx, disconnected_rx) = watch::channel(false); - let stream_span = tracing::debug_span!( - "noise_relay.stream", - noise_side = "harness", - environment_id = %environment_id, - executor_registration_id = %executor_registration_id, - stream_id = %stream_id, + let stream_span = tracing::debug_span!("noise_relay.stream", noise_side = "harness",); + debug!( + environment_id, + executor_registration_id, stream_id, "Noise harness relay details" ); let websocket_task = tokio::spawn(async move { 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 000000000000..d157f4c6dc62 --- /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 000000000000..1b0408afd85d --- /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 49b96b9e5013..99ee3431aa21 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -21,6 +21,9 @@ pub const EXEC_EXITED_METHOD: &str = "process/exited"; pub const EXEC_CLOSED_METHOD: &str = "process/closed"; pub const ENVIRONMENT_INFO_METHOD: &str = "environment/info"; pub const FS_READ_FILE_METHOD: &str = "fs/readFile"; +pub(crate) const FS_OPEN_METHOD: &str = "fs/open"; +pub(crate) const FS_READ_BLOCK_METHOD: &str = "fs/readBlock"; +pub(crate) const FS_CLOSE_METHOD: &str = "fs/close"; pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile"; pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory"; pub const FS_GET_METADATA_METHOD: &str = "fs/getMetadata"; @@ -100,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)] @@ -144,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)] @@ -151,6 +163,7 @@ pub struct ReadResponse { pub struct WriteParams { pub process_id: ProcessId, pub chunk: ByteChunk, + pub write_id: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -210,6 +223,45 @@ pub struct FsReadFileResponse { pub data_base64: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsOpenParams { + pub handle_id: String, + pub path: PathUri, + pub sandbox: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsOpenResponse { + pub handle_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsReadBlockParams { + pub handle_id: String, + pub offset: u64, + pub len: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsReadBlockResponse { + pub chunk: ByteChunk, + pub eof: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsCloseParams { + pub handle_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsCloseResponse {} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsWriteFileParams { diff --git a/codex-rs/exec-server/src/regular_file.rs b/codex-rs/exec-server/src/regular_file.rs new file mode 100644 index 000000000000..55540faddfb1 --- /dev/null +++ b/codex-rs/exec-server/src/regular_file.rs @@ -0,0 +1,48 @@ +use std::io; +use std::path::Path; + +pub(crate) async fn open(path: &Path) -> io::Result { + let mut options = tokio::fs::OpenOptions::new(); + options.read(true); + configure_open(&mut options); + + let file = options.open(path).await?; + if !is_disk_file(&file) || !file.metadata().await?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("path `{}` is not a file", path.display()), + )); + } + Ok(file) +} + +#[cfg(unix)] +fn configure_open(options: &mut tokio::fs::OpenOptions) { + options.custom_flags(libc::O_NONBLOCK); +} + +#[cfg(windows)] +fn configure_open(options: &mut tokio::fs::OpenOptions) { + use windows_sys::Win32::Storage::FileSystem::SECURITY_IDENTIFICATION; + + options.security_qos_flags(SECURITY_IDENTIFICATION); +} + +#[cfg(not(any(unix, windows)))] +fn configure_open(_options: &mut tokio::fs::OpenOptions) {} + +#[cfg(windows)] +fn is_disk_file(file: &tokio::fs::File) -> bool { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Storage::FileSystem::FILE_TYPE_DISK; + use windows_sys::Win32::Storage::FileSystem::GetFileType; + + // SAFETY: `file` owns this handle for the duration of the call. + unsafe { GetFileType(file.as_raw_handle() as HANDLE) == FILE_TYPE_DISK } +} + +#[cfg(not(windows))] +fn is_disk_file(_file: &tokio::fs::File) -> bool { + true +} diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index 1202ec2d120a..033da4b2a746 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -440,15 +440,7 @@ pub(crate) trait HarnessKeyValidator: Send + Sync { /// /// Parsing the first Noise message authenticates the harness key. Only a /// successful registry check turns that pending handshake into a virtual stream. -#[tracing::instrument( - level = "debug", - skip_all, - fields( - noise_side = "executor", - environment_id = %environment_id, - executor_registration_id = %executor_registration_id, - ) -)] +#[tracing::instrument(level = "debug", skip_all, fields(noise_side = "executor"))] pub(crate) async fn run_multiplexed_environment( stream: WebSocketStream, processor: ConnectionProcessor, @@ -460,6 +452,10 @@ pub(crate) async fn run_multiplexed_environment( S: AsyncRead + AsyncWrite + Unpin + Send + 'static, V: HarnessKeyValidator + Clone + 'static, { + debug!( + environment_id, + executor_registration_id, "Noise executor relay details" + ); let (mut websocket_sink, mut websocket_stream) = stream.split(); let (physical_outgoing_tx, mut physical_outgoing_rx) = mpsc::channel::>(CHANNEL_CAPACITY); diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 7a470f3d9580..199e7875428c 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -1,9 +1,14 @@ +use std::sync::Arc; use std::time::Duration; +use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use futures::FutureExt; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; use reqwest::StatusCode; use serde::Deserialize; -use serde::Serialize; use tokio::time::sleep; use tokio_tungstenite::connect_async_with_config; use tracing::debug; @@ -12,10 +17,19 @@ use tracing::warn; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use crate::EnvironmentRegistryConnectRequest; +use crate::EnvironmentRegistryConnectResponse; +use crate::EnvironmentRegistryHarnessKeyValidationRequest; +use crate::EnvironmentRegistryHarnessKeyValidationResponse; +use crate::EnvironmentRegistryRegistrationRequest; +use crate::EnvironmentRegistryRegistrationResponse; use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::NoiseChannelIdentity; use crate::NoiseChannelPublicKey; +use crate::NoiseRendezvousConnectBundle; +use crate::NoiseRendezvousConnectProvider; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::noise_relay::noise_relay_websocket_config; use crate::relay::HarnessKeyValidator; use crate::relay::run_multiplexed_environment; @@ -29,6 +43,7 @@ struct EnvironmentRegistryClient { base_url: String, auth_provider: SharedAuthProvider, http: reqwest::Client, + connect_timeout: Duration, } impl std::fmt::Debug for EnvironmentRegistryClient { @@ -49,6 +64,7 @@ impl EnvironmentRegistryClient { http: reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build()?, + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, }) } @@ -67,8 +83,8 @@ impl EnvironmentRegistryClient { )) .headers(self.auth_provider.to_auth_headers()) .json(&EnvironmentRegistryRegistrationRequest { - security_profile: NOISE_RELAY_SECURITY_PROFILE, - executor_public_key, + security_profile: NOISE_RELAY_SECURITY_PROFILE.to_string(), + executor_public_key: executor_public_key.clone(), }) .send() .await?; @@ -99,6 +115,53 @@ impl EnvironmentRegistryClient { Ok(response) } + /// Authorize one Noise harness key and obtain the full rendezvous bundle. + async fn connect_environment( + &self, + environment_id: &str, + harness_public_key: NoiseChannelPublicKey, + ) -> Result { + let response = self + .http + .post(endpoint_url( + &self.base_url, + &format!("/cloud/environment/{environment_id}/connect"), + )) + .headers(self.auth_provider.to_auth_headers()) + .json(&EnvironmentRegistryConnectRequest { harness_public_key }) + .timeout(self.connect_timeout) + .send() + .await?; + let response: EnvironmentRegistryConnectResponse = + self.parse_json_response(response).await?; + if response.environment_id != environment_id { + return Err(ExecServerError::Protocol( + "environment registry returned a different environment id".to_string(), + )); + } + if response.security_profile != NOISE_RELAY_SECURITY_PROFILE { + return Err(ExecServerError::Protocol(format!( + "environment registry returned unsupported security profile `{}`", + response.security_profile + ))); + } + if response.url.trim().is_empty() + || response.executor_registration_id.trim().is_empty() + || response.harness_key_authorization.trim().is_empty() + { + return Err(ExecServerError::Protocol( + "environment registry returned incomplete Noise connection data".to_string(), + )); + } + Ok(NoiseRendezvousConnectBundle { + websocket_url: response.url, + environment_id: response.environment_id, + executor_registration_id: response.executor_registration_id, + executor_public_key: response.executor_public_key, + harness_key_authorization: response.harness_key_authorization, + }) + } + async fn parse_json_response( &self, response: reqwest::Response, @@ -120,32 +183,6 @@ impl EnvironmentRegistryClient { } } -#[derive(Serialize)] -struct EnvironmentRegistryRegistrationRequest<'a> { - security_profile: &'static str, - executor_public_key: &'a NoiseChannelPublicKey, -} - -#[derive(Debug, Clone, Eq, PartialEq, Deserialize)] -struct EnvironmentRegistryRegistrationResponse { - environment_id: String, - url: String, - security_profile: String, - executor_registration_id: String, -} - -#[derive(Serialize)] -struct EnvironmentRegistryHarnessKeyValidationRequest<'a> { - executor_registration_id: &'a str, - harness_public_key: &'a NoiseChannelPublicKey, - harness_key_authorization: &'a str, -} - -#[derive(Deserialize)] -struct EnvironmentRegistryHarnessKeyValidationResponse { - valid: bool, -} - #[derive(Clone)] struct RegistryHarnessKeyValidator { client: EnvironmentRegistryClient, @@ -172,9 +209,9 @@ impl HarnessKeyValidator for RegistryHarnessKeyValidator { )) .headers(self.client.auth_provider.to_auth_headers()) .json(&EnvironmentRegistryHarnessKeyValidationRequest { - executor_registration_id: &self.executor_registration_id, - harness_public_key, - harness_key_authorization: authorization, + executor_registration_id: self.executor_registration_id.clone(), + harness_public_key: harness_public_key.clone(), + harness_key_authorization: authorization.to_string(), }) .send() .await?; @@ -205,6 +242,130 @@ impl HarnessKeyValidator for RegistryHarnessKeyValidator { } } +/// Noise connection configuration for a Codex harness. +/// +/// The provider holds the authenticated registry client so every reconnect +/// receives fresh URL and harness-key authorization material. +#[derive(Clone)] +pub(crate) struct NoiseRendezvousEnvironmentConfig { + provider: Arc, +} + +impl std::fmt::Debug for NoiseRendezvousEnvironmentConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NoiseRendezvousEnvironmentConfig") + .field("provider", &"") + .finish() + } +} + +impl NoiseRendezvousEnvironmentConfig { + pub(crate) fn new( + base_url: String, + environment_id: String, + bearer_token: String, + chatgpt_account_id: Option, + ) -> Result { + let environment_id = normalize_environment_id(environment_id)?; + let auth_provider = static_bearer_auth_provider(bearer_token, chatgpt_account_id)?; + let client = EnvironmentRegistryClient::new(base_url, auth_provider)?; + Ok(Self { + provider: Arc::new(EnvironmentRegistryNoiseConnectProvider { + client, + environment_id, + }), + }) + } + + pub(crate) fn connect_provider(&self) -> Arc { + Arc::clone(&self.provider) + } +} + +#[derive(Clone, Debug)] +struct EnvironmentRegistryNoiseConnectProvider { + client: EnvironmentRegistryClient, + environment_id: String, +} + +impl NoiseRendezvousConnectProvider for EnvironmentRegistryNoiseConnectProvider { + fn connect_bundle( + &self, + harness_public_key: NoiseChannelPublicKey, + ) -> futures::future::BoxFuture<'_, Result> { + async move { + self.client + .connect_environment(&self.environment_id, harness_public_key) + .await + } + .boxed() + } +} + +#[derive(Clone)] +struct StaticBearerAuthProvider { + authorization: HeaderValue, + chatgpt_account_id: Option, +} + +impl std::fmt::Debug for StaticBearerAuthProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticBearerAuthProvider") + .field("authorization", &"") + .field( + "chatgpt_account_id", + &self.chatgpt_account_id.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl AuthProvider for StaticBearerAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.insert(http::header::AUTHORIZATION, self.authorization.clone()); + if let Some(chatgpt_account_id) = &self.chatgpt_account_id { + headers.insert( + HeaderName::from_static("chatgpt-account-id"), + chatgpt_account_id.clone(), + ); + } + } +} + +fn static_bearer_auth_provider( + bearer_token: String, + chatgpt_account_id: Option, +) -> Result { + let bearer_token = bearer_token.trim(); + if bearer_token.is_empty() { + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment registry bearer token is required".to_string(), + )); + } + let authorization = + HeaderValue::try_from(format!("Bearer {bearer_token}")).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "environment registry bearer token is not a valid HTTP header: {error}" + )) + })?; + let chatgpt_account_id = chatgpt_account_id + .as_deref() + .map(str::trim) + .filter(|account_id| !account_id.is_empty()) + .map(|account_id| { + HeaderValue::try_from(account_id).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "ChatGPT account id is not a valid HTTP header: {error}" + )) + }) + }) + .transpose()?; + Ok(Arc::new(StaticBearerAuthProvider { + authorization, + chatgpt_account_id, + })) +} + /// Configuration for registering an exec-server for remote use. #[derive(Clone)] pub struct RemoteEnvironmentConfig { @@ -247,6 +408,11 @@ impl RemoteEnvironmentConfig { /// reconnects. The registration and rendezvous URL are also reused until /// rendezvous rejects the URL, at which point the next attempt registers again. /// The websocket carries cleartext routing metadata and encrypted payloads. +#[tracing::instrument( + name = "codex.exec_server", + skip_all, + fields(otel.kind = "internal") +)] pub async fn run_remote_environment( config: RemoteEnvironmentConfig, runtime_paths: ExecServerRuntimePaths, @@ -482,6 +648,86 @@ mod tests { ); } + #[tokio::test] + async fn noise_connect_provider_requests_and_validates_a_full_bundle() { + let server = MockServer::start().await; + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + let executor_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .and(header("authorization", "Bearer registry-token")) + .and(header("chatgpt-account-id", "workspace-123")) + .and(body_partial_json(serde_json::json!({ + "harness_public_key": harness_public_key.clone(), + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "environment_id": "environment-requested", + "url": "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc", + "security_profile": NOISE_RELAY_SECURITY_PROFILE, + "executor_registration_id": "registration-1", + "executor_public_key": executor_public_key.clone(), + "harness_key_authorization": "authorization-1", + }))) + .mount(&server) + .await; + let config = NoiseRendezvousEnvironmentConfig::new( + server.uri(), + "environment-requested".to_string(), + "registry-token".to_string(), + Some("workspace-123".to_string()), + ) + .expect("noise configuration"); + + let bundle = config + .connect_provider() + .connect_bundle(harness_public_key) + .await + .expect("Noise connect bundle"); + + assert_eq!( + bundle.websocket_url, + "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc" + ); + assert_eq!(bundle.environment_id, "environment-requested"); + assert_eq!(bundle.executor_registration_id, "registration-1"); + assert_eq!(bundle.executor_public_key, executor_public_key); + assert_eq!(bundle.harness_key_authorization, "authorization-1"); + } + + #[tokio::test] + async fn connect_environment_times_out_when_registry_stalls() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) + .mount(&server) + .await; + let mut client = + EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + client.connect_timeout = Duration::from_millis(50); + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + + let error = match client + .connect_environment("environment-requested", harness_public_key) + .await + { + Ok(_) => panic!("stalled connect response should time out"), + Err(error) => error, + }; + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryRequest(error) if error.is_timeout() + )); + } + #[tokio::test] async fn register_environment_does_not_follow_redirects_with_auth_headers() { let server = MockServer::start().await; diff --git a/codex-rs/exec-server/src/remote_file_stream.rs b/codex-rs/exec-server/src/remote_file_stream.rs new file mode 100644 index 000000000000..107dea51d7d5 --- /dev/null +++ b/codex-rs/exec-server/src/remote_file_stream.rs @@ -0,0 +1,121 @@ +use bytes::Bytes; +use codex_utils_path_uri::PathUri; +use tokio::io; +use uuid::Uuid; + +use super::map_remote_error; +use crate::ExecServerClient; +use crate::FILE_READ_CHUNK_SIZE; +use crate::FileSystemReadStream; +use crate::FileSystemResult; +use crate::FileSystemSandboxContext; +use crate::protocol::FS_READ_BLOCK_METHOD; +use crate::protocol::FsCloseParams; +use crate::protocol::FsOpenParams; +use crate::protocol::FsReadBlockParams; + +struct FileReadRegistration { + client: ExecServerClient, + handle_id: String, + runtime: Option, + active: bool, +} + +pub(super) async fn open( + client: ExecServerClient, + path: PathUri, + sandbox: Option, +) -> FileSystemResult { + let registration = FileReadRegistration { + client, + handle_id: Uuid::new_v4().simple().to_string(), + runtime: tokio::runtime::Handle::try_current().ok(), + active: true, + }; + registration + .client + .fs_open(FsOpenParams { + handle_id: registration.handle_id.clone(), + path, + sandbox, + }) + .await + .map_err(map_remote_error)?; + Ok(FileSystemReadStream::new(futures::stream::try_unfold( + Some((registration, 0_u64)), + |state| async move { + let Some((mut registration, offset)) = state else { + return Ok(None); + }; + let response = registration + .client + .fs_read_block(FsReadBlockParams { + handle_id: registration.handle_id.clone(), + offset, + len: FILE_READ_CHUNK_SIZE, + }) + .await + .map_err(map_remote_error)?; + let chunk = Bytes::from(response.chunk.into_inner()); + if chunk.len() > FILE_READ_CHUNK_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{FS_READ_BLOCK_METHOD} returned {} bytes, maximum is {}", + chunk.len(), + FILE_READ_CHUNK_SIZE + ), + )); + } + if response.eof { + if registration + .client + .fs_close(FsCloseParams { + handle_id: registration.handle_id.clone(), + }) + .await + .is_ok() + { + registration.active = false; + } + return if chunk.is_empty() { + Ok(None) + } else { + Ok(Some((chunk, None))) + }; + } + if chunk.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{FS_READ_BLOCK_METHOD} returned an empty non-terminal block"), + )); + } + let next_offset = offset.checked_add(chunk.len() as u64).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{FS_READ_BLOCK_METHOD} offset overflowed after {offset} bytes"), + ) + })?; + Ok(Some((chunk, Some((registration, next_offset))))) + }, + ))) +} + +impl Drop for FileReadRegistration { + fn drop(&mut self) { + if !self.active { + return; + } + let client = self.client.clone(); + let handle_id = self.handle_id.clone(); + let runtime = self + .runtime + .clone() + .or_else(|| tokio::runtime::Handle::try_current().ok()); + if let Some(runtime) = runtime { + runtime.spawn(async move { + let _ = client.fs_close(FsCloseParams { handle_id }).await; + }); + } + } +} diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index 6d46d489a494..2314c3357dec 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -10,6 +10,7 @@ use crate::ExecServerError; use crate::ExecutorFileSystem; use crate::ExecutorFileSystemFuture; use crate::FileMetadata; +use crate::FileSystemReadStream; use crate::FileSystemResult; use crate::FileSystemSandboxContext; use crate::ReadDirectoryEntry; @@ -27,6 +28,9 @@ use crate::protocol::FsWriteFileParams; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; const NOT_FOUND_ERROR_CODE: i64 = -32004; +#[path = "remote_file_stream.rs"] +mod file_stream; + pub(crate) struct RemoteFileSystem { client: LazyRemoteExecServerClient, } @@ -76,6 +80,22 @@ impl RemoteFileSystem { }) } + async fn read_file_stream( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "streaming file reads do not support platform sandboxing", + )); + } + trace!("remote fs read_file_stream"); + let client = self.client.get().await.map_err(map_remote_error)?; + file_stream::open(client, path.clone(), remote_sandbox_context(sandbox)).await + } + async fn write_file( &self, path: &PathUri, @@ -223,6 +243,14 @@ impl ExecutorFileSystem for RemoteFileSystem { Box::pin(RemoteFileSystem::read_file(self, path, sandbox)) } + fn read_file_stream<'a>( + &'a self, + path: &'a PathUri, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(RemoteFileSystem::read_file_stream(self, path, sandbox)) + } + fn write_file<'a>( &'a self, path: &'a PathUri, diff --git a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs index 29284e6ad905..1205c6d7e436 100644 --- a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs +++ b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs @@ -23,6 +23,7 @@ use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; use super::*; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::ExecServerTransportParams; use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FsReadFileParams; @@ -36,7 +37,10 @@ async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conve let (websocket_url, captured_params, server) = record_read_file_params(/*expected_requests*/ 2).await; let file_system = RemoteFileSystem::new(LazyRemoteExecServerClient::new( - ExecServerTransportParams::websocket_url(websocket_url), + ExecServerTransportParams::websocket_url( + websocket_url, + DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + ), )); let paths = vec![ PathUri::parse("file:///C:/Users/Alice/src/main.rs").expect("valid drive URI"), diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index e130114cc34d..72f41ae70e09 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -35,13 +35,8 @@ impl RemoteProcess { &self, params: ExecParams, ) -> Result { - let process_id = params.process_id.clone(); let client = self.client.get().await?; - let session = client.register_session(&process_id).await?; - if let Err(err) = client.exec(params).await { - session.unregister().await; - return Err(err); - } + let session = client.start_process(params).await?; Ok(StartedExecProcess { process: Arc::new(RemoteExecProcess { session }), diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index 981a1c1a802f..82cf2b200a95 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -2,6 +2,7 @@ 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::AtomicI64; use std::sync::atomic::Ordering; @@ -25,6 +26,8 @@ use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; +pub(crate) const SESSION_ALREADY_ATTACHED_ERROR_CODE: i64 = -32010; + #[derive(Debug)] pub(crate) enum RpcCallError { /// The underlying JSON-RPC transport closed before this call completed. @@ -225,6 +228,7 @@ pub(crate) struct RpcClient { // immediately when the socket closes, even if no JSON-RPC error response // can be delivered for their request id. disconnected_rx: watch::Receiver, + closed: Arc, next_request_id: AtomicI64, transport_tasks: Vec>, transport: JsonRpcTransport, @@ -241,9 +245,11 @@ impl RpcClient { transport, } = connection; let pending = Arc::new(Mutex::new(HashMap::::new())); + let closed = Arc::new(AtomicBool::new(false)); let (event_tx, event_rx) = mpsc::channel(128); let pending_for_reader = Arc::clone(&pending); + let closed_for_reader = Arc::clone(&closed); let transport_for_reader = transport.clone(); let reader_task = tokio::spawn(async move { let disconnect_reason = loop { @@ -269,12 +275,13 @@ impl RpcClient { } }; + closed_for_reader.store(true, Ordering::Release); + drain_pending(&pending_for_reader).await; let _ = event_tx .send(RpcClientEvent::Disconnected { reason: disconnect_reason, }) .await; - drain_pending(&pending_for_reader).await; transport_for_reader.terminate(); }); @@ -283,6 +290,7 @@ impl RpcClient { write_tx, pending, disconnected_rx, + closed, next_request_id: AtomicI64::new(1), transport_tasks, transport, @@ -296,24 +304,31 @@ impl RpcClient { &self, method: &str, params: &P, - ) -> Result<(), serde_json::Error> { - let params = serde_json::to_value(params)?; + ) -> Result<(), RpcCallError> { + let params = serde_json::to_value(params).map_err(RpcCallError::Json)?; + if self.closed.load(Ordering::Acquire) || *self.disconnected_rx.borrow() { + return Err(RpcCallError::Closed); + } self.write_tx .send(JSONRPCMessage::Notification(JSONRPCNotification { method: method.to_string(), params: Some(params), })) .await - .map_err(|_| { - serde_json::Error::io(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "JSON-RPC transport closed", - )) - }) + .map_err(|_| RpcCallError::Closed) } pub(crate) fn is_disconnected(&self) -> bool { - *self.disconnected_rx.borrow() + self.closed.load(Ordering::Acquire) || *self.disconnected_rx.borrow() + } + + pub(crate) async fn close_transport(&self) { + self.closed.store(true, Ordering::Release); + self.transport.terminate(); + for task in &self.transport_tasks { + task.abort(); + } + drain_pending(&self.pending).await; } pub(crate) async fn call(&self, method: &str, params: &P) -> Result @@ -328,7 +343,7 @@ impl RpcClient { // Registering the pending request and checking disconnect must be // atomic with the reader's drain_pending path. Otherwise a call // can sneak in after the drain and wait forever. - if *self.disconnected_rx.borrow() { + if self.closed.load(Ordering::Acquire) || *self.disconnected_rx.borrow() { return Err(RpcCallError::Closed); } pending.insert(request_id.clone(), response_tx); @@ -417,6 +432,14 @@ pub(crate) fn invalid_request(message: String) -> JSONRPCErrorError { } } +pub(crate) fn session_already_attached(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: SESSION_ALREADY_ATTACHED_ERROR_CODE, + data: None, + message, + } +} + pub(crate) fn method_not_found(message: String) -> JSONRPCErrorError { JSONRPCErrorError { code: -32601, diff --git a/codex-rs/exec-server/src/sandboxed_file_system.rs b/codex-rs/exec-server/src/sandboxed_file_system.rs index d4cc31c42702..405233c3d6e3 100644 --- a/codex-rs/exec-server/src/sandboxed_file_system.rs +++ b/codex-rs/exec-server/src/sandboxed_file_system.rs @@ -10,6 +10,7 @@ use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; use crate::ExecutorFileSystemFuture; use crate::FileMetadata; +use crate::FileSystemReadStream; use crate::FileSystemResult; use crate::FileSystemSandboxContext; use crate::ReadDirectoryEntry; @@ -266,6 +267,19 @@ impl ExecutorFileSystem for SandboxedFileSystem { Box::pin(SandboxedFileSystem::read_file(self, path, sandbox)) } + fn read_file_stream<'a>( + &'a self, + _path: &'a PathUri, + _sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(async { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "streaming file reads do not support platform sandboxing", + )) + }) + } + fn write_file<'a>( &'a self, path: &'a PathUri, diff --git a/codex-rs/exec-server/src/server.rs b/codex-rs/exec-server/src/server.rs index bf33eb77ba98..d443b0ed1ff8 100644 --- a/codex-rs/exec-server/src/server.rs +++ b/codex-rs/exec-server/src/server.rs @@ -13,6 +13,11 @@ pub use transport::ExecServerListenUrlParseError; use crate::ExecServerRuntimePaths; +#[tracing::instrument( + name = "codex.exec_server", + skip_all, + fields(otel.kind = "internal") +)] pub async fn run_main( listen_url: &str, runtime_paths: ExecServerRuntimePaths, 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 080d4829d089..ddf17f21cd93 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -9,16 +9,23 @@ use crate::CreateDirectoryOptions; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; use crate::RemoveOptions; +use crate::file_read::FileReadHandleManager; use crate::local_file_system::LocalFileSystem; use crate::protocol::FS_WRITE_FILE_METHOD; use crate::protocol::FsCanonicalizeParams; use crate::protocol::FsCanonicalizeResponse; +use crate::protocol::FsCloseParams; +use crate::protocol::FsCloseResponse; use crate::protocol::FsCopyParams; use crate::protocol::FsCopyResponse; use crate::protocol::FsCreateDirectoryParams; use crate::protocol::FsCreateDirectoryResponse; use crate::protocol::FsGetMetadataParams; use crate::protocol::FsGetMetadataResponse; +use crate::protocol::FsOpenParams; +use crate::protocol::FsOpenResponse; +use crate::protocol::FsReadBlockParams; +use crate::protocol::FsReadBlockResponse; use crate::protocol::FsReadDirectoryEntry; use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadDirectoryResponse; @@ -32,18 +39,69 @@ use crate::rpc::internal_error; use crate::rpc::invalid_request; use crate::rpc::not_found; +const MAX_FILE_READ_HANDLE_ID_BYTES: usize = 32; + #[derive(Clone)] pub(crate) struct FileSystemHandler { file_system: LocalFileSystem, + file_reads: FileReadHandleManager, } impl FileSystemHandler { pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self { Self { file_system: LocalFileSystem::with_runtime_paths(runtime_paths), + file_reads: FileReadHandleManager::default(), } } + pub(crate) async fn shutdown(&self) { + self.file_reads.close_all().await; + } + + pub(crate) async fn open( + &self, + params: FsOpenParams, + ) -> Result { + validate_file_read_handle_id(¶ms.handle_id)?; + let file = self + .file_system + .open_file_for_read(¶ms.path, params.sandbox.as_ref()) + .await + .map_err(map_fs_error)?; + let handle_id = self + .file_reads + .open(params.handle_id, file) + .await + .map_err(map_fs_error)?; + Ok(FsOpenResponse { handle_id }) + } + + pub(crate) async fn read_block( + &self, + params: FsReadBlockParams, + ) -> Result { + validate_file_read_handle_id(¶ms.handle_id)?; + let block = self + .file_reads + .read_block(¶ms.handle_id, params.offset, params.len) + .await + .map_err(map_fs_error)?; + Ok(FsReadBlockResponse { + chunk: block.bytes.into(), + eof: block.eof, + }) + } + + pub(crate) async fn close( + &self, + params: FsCloseParams, + ) -> Result { + validate_file_read_handle_id(¶ms.handle_id)?; + self.file_reads.close(¶ms.handle_id).await; + Ok(FsCloseResponse {}) + } + pub(crate) async fn read_file( &self, params: FsReadFileParams, @@ -176,6 +234,15 @@ impl FileSystemHandler { } } +fn validate_file_read_handle_id(handle_id: &str) -> Result<(), JSONRPCErrorError> { + if handle_id.len() > MAX_FILE_READ_HANDLE_ID_BYTES { + return Err(invalid_request(format!( + "file read handle ID must not exceed {MAX_FILE_READ_HANDLE_ID_BYTES} bytes" + ))); + } + Ok(()) +} + fn map_fs_error(err: io::Error) -> JSONRPCErrorError { match err.kind() { io::ErrorKind::NotFound => not_found(err.to_string()), diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 73e8f22684e6..07575a1c42eb 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -19,12 +19,18 @@ use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::FsCanonicalizeParams; use crate::protocol::FsCanonicalizeResponse; +use crate::protocol::FsCloseParams; +use crate::protocol::FsCloseResponse; use crate::protocol::FsCopyParams; use crate::protocol::FsCopyResponse; use crate::protocol::FsCreateDirectoryParams; use crate::protocol::FsCreateDirectoryResponse; use crate::protocol::FsGetMetadataParams; use crate::protocol::FsGetMetadataResponse; +use crate::protocol::FsOpenParams; +use crate::protocol::FsOpenResponse; +use crate::protocol::FsReadBlockParams; +use crate::protocol::FsReadBlockResponse; use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadDirectoryResponse; use crate::protocol::FsReadFileParams; @@ -60,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, } @@ -77,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), } @@ -87,6 +95,7 @@ impl ExecServerHandler { self.background_task_shutdown.cancel(); self.background_tasks.close(); self.background_tasks.wait().await; + self.file_system.shutdown().await; if let Some(session) = self.session() { session.detach().await; } @@ -109,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, @@ -234,6 +247,30 @@ impl ExecServerHandler { self.file_system.read_file(params).await } + pub(crate) async fn fs_open( + &self, + params: FsOpenParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.open(params).await + } + + pub(crate) async fn fs_read_block( + &self, + params: FsReadBlockParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.read_block(params).await + } + + pub(crate) async fn fs_close( + &self, + params: FsCloseParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.close(params).await + } + pub(crate) async fn fs_write_file( &self, params: FsWriteFileParams, diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 97bfba534473..9cbbf78d0055 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -33,6 +33,8 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } @@ -257,7 +259,7 @@ async fn active_session_resume_is_rejected() { .await .expect_err("active session resume should fail"); - assert_eq!(err.code, -32600); + assert_eq!(err.code, crate::rpc::SESSION_ALREADY_ATTACHED_ERROR_CODE); assert_eq!( err.message, format!( diff --git a/codex-rs/exec-server/src/server/process_handler.rs b/codex-rs/exec-server/src/server/process_handler.rs index 9fced9c166aa..f628b242b072 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 952f4142138c..f5c2ba760d5c 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(); @@ -403,6 +438,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index 4ba7ce865195..8f48aeaf99b7 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -8,17 +8,23 @@ use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; use crate::protocol::ExecParams; use crate::protocol::FS_CANONICALIZE_METHOD; +use crate::protocol::FS_CLOSE_METHOD; use crate::protocol::FS_COPY_METHOD; use crate::protocol::FS_CREATE_DIRECTORY_METHOD; use crate::protocol::FS_GET_METADATA_METHOD; +use crate::protocol::FS_OPEN_METHOD; +use crate::protocol::FS_READ_BLOCK_METHOD; use crate::protocol::FS_READ_DIRECTORY_METHOD; use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FS_REMOVE_METHOD; use crate::protocol::FS_WRITE_FILE_METHOD; use crate::protocol::FsCanonicalizeParams; +use crate::protocol::FsCloseParams; use crate::protocol::FsCopyParams; use crate::protocol::FsCreateDirectoryParams; use crate::protocol::FsGetMetadataParams; +use crate::protocol::FsOpenParams; +use crate::protocol::FsReadBlockParams; use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadFileParams; use crate::protocol::FsRemoveParams; @@ -93,6 +99,24 @@ pub(crate) fn build_router() -> RpcRouter { handler.fs_read_file(params).await }, ); + router.request( + FS_OPEN_METHOD, + |handler: Arc, params: FsOpenParams| async move { + handler.fs_open(params).await + }, + ); + router.request( + FS_READ_BLOCK_METHOD, + |handler: Arc, params: FsReadBlockParams| async move { + handler.fs_read_block(params).await + }, + ); + router.request( + FS_CLOSE_METHOD, + |handler: Arc, params: FsCloseParams| async move { + handler.fs_close(params).await + }, + ); router.request( FS_WRITE_FILE_METHOD, |handler: Arc, params: FsWriteFileParams| async move { diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 82c779c6bb3d..73b14ff7cd78 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -7,14 +7,16 @@ 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; use crate::server::process_handler::ProcessHandler; #[cfg(test)] const DETACHED_SESSION_TTL: Duration = Duration::from_millis(200); #[cfg(not(test))] -const DETACHED_SESSION_TTL: Duration = Duration::from_secs(10); +const DETACHED_SESSION_TTL: Duration = Duration::from_secs(30); pub(crate) struct SessionRegistry { sessions: Mutex>>, @@ -59,6 +61,7 @@ impl SessionRegistry { self: &Arc, resume_session_id: Option, notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, ) -> Result { enum AttachOutcome { Attached(Arc), @@ -82,7 +85,7 @@ impl SessionRegistry { })?; Ok(AttachOutcome::Expired { session_id, entry }) } else if entry.has_active_connection() { - Err(invalid_request(format!( + Err(session_already_attached(format!( "session {session_id} is already attached to another connection" ))) } else { @@ -94,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)); @@ -176,6 +179,7 @@ impl SessionEntry { return false; } + self.process.set_notification_sender(/*notifications*/ None); attachment.current_connection_id = None; attachment.detached_connection_id = Some(connection_id); attachment.detached_expires_at = Some(tokio::time::Instant::now() + DETACHED_SESSION_TTL); @@ -245,10 +249,6 @@ impl SessionHandle { return; } - self.entry - .process - .set_notification_sender(/*notifications*/ None); - let registry = Arc::clone(&self.registry); let session_id = self.entry.session_id.clone(); let connection_id = self.connection_id; diff --git a/codex-rs/exec-server/testing/wine_exec_server.rs b/codex-rs/exec-server/testing/wine_exec_server.rs index 88857e785a0e..a3643f8668c9 100644 --- a/codex-rs/exec-server/testing/wine_exec_server.rs +++ b/codex-rs/exec-server/testing/wine_exec_server.rs @@ -1,6 +1,7 @@ //! Test support for running the Windows exec-server under Wine. use std::future::Future; +use std::path::PathBuf; use anyhow::Context; use anyhow::Result; @@ -12,16 +13,18 @@ use wine_test_support::WineTestCommand; pub struct WineExecServer; impl WineExecServer { - /// Starts the server, passes its WebSocket URL to `operation`, and tears it down afterward. + /// Starts the server, passes its WebSocket URL and Wine prefix to `operation`, and tears it + /// down afterward. pub async fn scope(self, operation: F) -> Result where - F: FnOnce(String) -> Fut, + F: FnOnce(String, PathBuf) -> Fut, Fut: Future>, { let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; let mut exec_server = WineTestCommand::new(executable) .env("CODEX_HOME", r"C:\codex-home") .spawn()?; + let wine_prefix = exec_server.prefix_path().to_path_buf(); let stdout = exec_server.take_stdout(); exec_server @@ -36,7 +39,7 @@ impl WineExecServer { break line; } }; - operation(exec_server_url).await + operation(exec_server_url, wine_prefix).await }) .await } diff --git a/codex-rs/exec-server/testing/wine_remote_test_runner.rs b/codex-rs/exec-server/testing/wine_remote_test_runner.rs index 2889415675b2..6e73e68e9b6c 100644 --- a/codex-rs/exec-server/testing/wine_remote_test_runner.rs +++ b/codex-rs/exec-server/testing/wine_remote_test_runner.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { } WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, _wine_prefix| async move { let mut command = Command::new(test_binary); command .env(TEST_ENVIRONMENT_ENV_VAR, "wine-exec") diff --git a/codex-rs/exec-server/tests/common/exec_server.rs b/codex-rs/exec-server/tests/common/exec_server.rs index f1bf03d25bee..ad089d5cda2e 100644 --- a/codex-rs/exec-server/tests/common/exec_server.rs +++ b/codex-rs/exec-server/tests/common/exec_server.rs @@ -14,8 +14,13 @@ use futures::StreamExt; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; +use tokio::io::copy_bidirectional; +use tokio::net::TcpListener; +use tokio::net::TcpStream; use tokio::process::Child; use tokio::process::Command; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; use tokio::time::Instant; use tokio::time::sleep; use tokio::time::timeout; @@ -48,6 +53,20 @@ pub(crate) struct TestCodexHelperPaths { pub(crate) codex_linux_sandbox_exe: Option, } +pub(crate) struct DisconnectableWebSocketProxy { + websocket_url: String, + pause_tx: Option>, + blocked_connection_rx: Option>, + resume_tx: Option>, + task: JoinHandle<()>, +} + +impl Drop for DisconnectableWebSocketProxy { + fn drop(&mut self) { + self.task.abort(); + } +} + pub(crate) fn test_codex_helper_paths() -> anyhow::Result { let (helper_binary, codex_linux_sandbox_exe) = super::current_test_binary_helper_paths()?; Ok(TestCodexHelperPaths { @@ -106,6 +125,35 @@ impl ExecServerHarness { Ok(()) } + pub(crate) async fn disconnectable_websocket_proxy( + &self, + ) -> anyhow::Result { + let upstream = self + .websocket_url + .strip_prefix("ws://") + .ok_or_else(|| anyhow!("exec-server websocket URL must use ws://"))? + .to_string(); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let (pause_tx, pause_rx) = oneshot::channel(); + let (blocked_connection_tx, blocked_connection_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + let task = tokio::spawn(run_disconnectable_proxy( + listener, + upstream, + pause_rx, + blocked_connection_tx, + resume_rx, + )); + Ok(DisconnectableWebSocketProxy { + websocket_url, + pause_tx: Some(pause_tx), + blocked_connection_rx: Some(blocked_connection_rx), + resume_tx: Some(resume_tx), + task, + }) + } + pub(crate) async fn send_request( &mut self, method: &str, @@ -213,6 +261,85 @@ impl ExecServerHarness { } } +impl DisconnectableWebSocketProxy { + pub(crate) fn websocket_url(&self) -> &str { + &self.websocket_url + } + + pub(crate) async fn pause_and_disconnect(&mut self) -> anyhow::Result<()> { + self.pause_tx + .take() + .ok_or_else(|| anyhow!("disconnectable websocket proxy is already paused"))? + .send(()) + .map_err(|_| anyhow!("disconnectable websocket proxy stopped"))?; + let blocked_connection_rx = self + .blocked_connection_rx + .take() + .ok_or_else(|| anyhow!("disconnectable websocket proxy is already paused"))?; + timeout(CONNECT_TIMEOUT, blocked_connection_rx) + .await + .map_err(|_| anyhow!("timed out waiting for client reconnect attempt"))? + .map_err(|_| anyhow!("disconnectable websocket proxy stopped"))?; + Ok(()) + } + + pub(crate) fn resume(&mut self) -> anyhow::Result<()> { + self.resume_tx + .take() + .ok_or_else(|| anyhow!("disconnectable websocket proxy is already resumed"))? + .send(()) + .map_err(|_| anyhow!("disconnectable websocket proxy stopped"))?; + Ok(()) + } +} + +async fn run_disconnectable_proxy( + listener: TcpListener, + upstream: String, + pause_rx: oneshot::Receiver<()>, + blocked_connection_tx: oneshot::Sender<()>, + mut resume_rx: oneshot::Receiver<()>, +) { + let Ok((mut downstream, _)) = listener.accept().await else { + return; + }; + let Ok(mut upstream_stream) = TcpStream::connect(&upstream).await else { + return; + }; + tokio::select! { + _ = copy_bidirectional(&mut downstream, &mut upstream_stream) => return, + _ = pause_rx => {} + } + drop(downstream); + drop(upstream_stream); + + let mut blocked_connection_tx = Some(blocked_connection_tx); + loop { + tokio::select! { + _ = &mut resume_rx => break, + accepted = listener.accept() => { + let Ok((blocked, _)) = accepted else { + break; + }; + drop(blocked); + if let Some(blocked_connection_tx) = blocked_connection_tx.take() { + let _ = blocked_connection_tx.send(()); + } + } + } + } + + loop { + let Ok((mut downstream, _)) = listener.accept().await else { + return; + }; + let Ok(mut upstream_stream) = TcpStream::connect(&upstream).await else { + continue; + }; + let _ = copy_bidirectional(&mut downstream, &mut upstream_stream).await; + } +} + async fn connect_websocket_when_ready( websocket_url: &str, ) -> anyhow::Result<( diff --git a/codex-rs/exec-server/tests/common/mod.rs b/codex-rs/exec-server/tests/common/mod.rs index 387edf36db77..9eba3c87c6c3 100644 --- a/codex-rs/exec-server/tests/common/mod.rs +++ b/codex-rs/exec-server/tests/common/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod exec_server; pub(crate) const DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG: &str = "--codex-test-delayed-output-after-exit-parent"; +const CODEX_WINDOWS_SANDBOX_ARG1: &str = "--run-as-windows-sandbox"; const DELAYED_OUTPUT_AFTER_EXIT_CHILD_ARG: &str = "--codex-test-delayed-output-after-exit-child"; #[ctor] @@ -27,6 +28,9 @@ pub static TEST_BINARY_DISPATCH_GUARD: Option = { if argv1 == Some(CODEX_FS_HELPER_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } + if argv1 == Some(CODEX_WINDOWS_SANDBOX_ARG1) { + return TestBinaryDispatchMode::DispatchArg0Only; + } if exe_name == CODEX_LINUX_SANDBOX_ARG0 { return TestBinaryDispatchMode::DispatchArg0Only; } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index e5522f4073d7..f35923815d45 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -1,5 +1,6 @@ mod common; +use std::collections::HashMap; use std::sync::Arc; use anyhow::Context; @@ -21,6 +22,7 @@ use tempfile::TempDir; use test_case::test_case; use tokio::sync::watch; use tokio::time::Duration; +use tokio::time::sleep; use tokio::time::timeout; use common::DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG; @@ -30,7 +32,7 @@ use common::exec_server::exec_server; struct ProcessContext { backend: Arc, - server: Option, + _server: Option, } #[derive(Debug, PartialEq, Eq)] @@ -55,13 +57,13 @@ async fn create_process_context(use_remote: bool) -> Result { let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; Ok(ProcessContext { backend: environment.get_exec_backend(), - server: Some(server), + _server: Some(server), }) } else { let environment = Environment::create_for_tests(/*exec_server_url*/ None)?; Ok(ProcessContext { backend: environment.get_exec_backend(), - server: None, + _server: None, }) } } @@ -79,6 +81,8 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -220,11 +224,13 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { 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"); @@ -251,11 +257,13 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { 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, @@ -298,11 +306,13 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res 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!( @@ -346,11 +356,13 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( 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), @@ -419,13 +431,15 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { 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?; @@ -456,6 +470,8 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -463,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?; @@ -489,13 +505,15 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) 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?; @@ -523,11 +541,13 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu 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; @@ -576,6 +596,8 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -616,12 +638,14 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( 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"); @@ -634,88 +658,147 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( #[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")] // Serialize tests that launch a real exec-server process through the full CLI. #[serial_test::serial(remote_exec_server)] -async fn remote_exec_process_reports_transport_disconnect() -> Result<()> { - let mut context = create_process_context(/*use_remote*/ true).await?; - let session = context - .backend +async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> { + let server = exec_server().await?; + let mut proxy = server.disconnectable_websocket_proxy().await?; + let environment = Environment::create_for_tests(Some(proxy.websocket_url().to_string()))?; + let backend = environment.get_exec_backend(); + let temp_dir = TempDir::new()?; + let gate_path = temp_dir.path().join("release-output"); + let emitted_path = temp_dir.path().join("output-emitted"); + let session = backend .start(ExecParams { - process_id: ProcessId::from("proc-disconnect"), + process_id: ProcessId::from("proc-recover"), argv: vec![ "/bin/sh".to_string(), "-c".to_string(), - "sleep 10".to_string(), + concat!( + "printf 'ready:%s\\n' \"$$\"; ", + "while [ ! -f \"$GATE\" ]; do /bin/sleep 0.01; done; ", + "printf 'during:%s\\n' \"$$\"; ", + ": > \"$EMITTED\"; ", + "IFS= read -r line; ", + "printf 'after:%s:%s\\n' \"$$\" \"$line\"; ", + "exit 7", + ) + .to_string(), ], cwd: PathUri::from_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, - env: Default::default(), + env: HashMap::from([ + ( + "GATE".to_string(), + gate_path.to_string_lossy().into_owned(), + ), + ( + "EMITTED".to_string(), + emitted_path.to_string_lossy().into_owned(), + ), + ]), tty: false, - pipe_stdin: false, + pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; let process = Arc::clone(&session.process); let mut events = process.subscribe_events(); - let process_for_pending_read = Arc::clone(&process); - let pending_read = tokio::spawn(async move { - process_for_pending_read + let mut output = Vec::new(); + let mut last_seq = 0; + while !output.ends_with(b"\n") { + match timeout(Duration::from_secs(5), events.recv()).await?? { + ExecProcessEvent::Output(chunk) => { + assert_eq!(chunk.seq, last_seq + 1); + last_seq = chunk.seq; + output.extend_from_slice(&chunk.chunk.into_inner()); + } + event => anyhow::bail!("expected ready output before disconnect, got {event:?}"), + } + } + let ready = String::from_utf8(output.clone())?; + let pid = ready + .strip_prefix("ready:") + .and_then(|line| line.strip_suffix('\n')) + .context("ready output should contain the process id")? + .to_string(); + + proxy.pause_and_disconnect().await?; + tokio::fs::write(&gate_path, b"").await?; + timeout(Duration::from_secs(5), async { + while tokio::fs::metadata(&emitted_path).await.is_err() { + sleep(Duration::from_millis(10)).await; + } + }) + .await + .context("process did not emit output while disconnected")?; + + let process_for_read = Arc::clone(&process); + let mut pending_read = tokio::spawn(async move { + process_for_read .read( - /*after_seq*/ None, + /*after_seq*/ Some(last_seq), /*max_bytes*/ None, - /*wait_ms*/ Some(60_000), + /*wait_ms*/ Some(0), ) .await }); - let server = context - .server - .as_mut() - .expect("remote context should include exec-server harness"); - server.shutdown().await?; - - let event = timeout(Duration::from_secs(2), events.recv()).await??; - let ExecProcessEvent::Failed(event_message) = event else { - anyhow::bail!("expected process failure event, got {event:?}"); - }; assert!( - event_message.starts_with("exec-server transport disconnected"), - "unexpected failure event: {event_message}" + timeout(Duration::from_millis(200), &mut pending_read) + .await + .is_err(), + "process reads should wait while recovery is in progress" ); + proxy.resume()?; - let pending_response = timeout(Duration::from_secs(2), pending_read).await???; - let pending_message = pending_response - .failure - .expect("pending read should surface disconnect as a failure"); - assert!( - pending_message.starts_with("exec-server transport disconnected"), - "unexpected pending failure message: {pending_message}" + let recovered_read = timeout(Duration::from_secs(5), pending_read) + .await + .context("timed out waiting for a read after recovery")??; + let recovered_read = recovered_read?; + assert_eq!(recovered_read.failure, None); + let recovered_output = recovered_read + .chunks + .into_iter() + .flat_map(|chunk| chunk.chunk.into_inner()) + .collect::>(); + assert_eq!( + String::from_utf8(recovered_output)?, + format!("during:{pid}\n") ); - let mut wake_rx = process.subscribe_wake(); - let response = read_process_until_change(process, &mut wake_rx, /*after_seq*/ None).await?; - let message = response - .failure - .expect("disconnect should surface as a failure"); - assert!( - message.starts_with("exec-server transport disconnected"), - "unexpected failure message: {message}" - ); - assert!( - response.closed, - "disconnect should close the process session" - ); + let write = timeout(Duration::from_secs(5), process.write(b"hello\n".to_vec())) + .await + .context("timed out waiting for a write after recovery")??; + assert_eq!(write.status, WriteStatus::Accepted); - let write_result = timeout( - Duration::from_secs(2), - session.process.write(b"hello".to_vec()), - ) - .await - .context("timed out waiting for write after disconnect")?; - let write_error = write_result.expect_err("write after disconnect should fail"); - assert!( - write_error - .to_string() - .starts_with("exec-server transport disconnected"), - "unexpected write error: {write_error}" + let mut saw_exit = false; + loop { + match timeout(Duration::from_secs(5), events.recv()).await?? { + ExecProcessEvent::Output(chunk) => { + assert_eq!(chunk.seq, last_seq + 1); + last_seq = chunk.seq; + output.extend_from_slice(&chunk.chunk.into_inner()); + } + ExecProcessEvent::Exited { seq, exit_code } => { + assert_eq!(seq, last_seq + 1); + assert_eq!(exit_code, 7); + last_seq = seq; + saw_exit = true; + } + ExecProcessEvent::Closed { seq } => { + assert!(saw_exit, "closed must be delivered after exit"); + assert_eq!(seq, last_seq + 1); + break; + } + ExecProcessEvent::Failed(message) => { + anyhow::bail!("process recovery failed: {message}"); + } + } + } + assert_eq!( + String::from_utf8(output)?, + format!("ready:{pid}\nduring:{pid}\nafter:{pid}:hello\n") ); Ok(()) diff --git a/codex-rs/exec-server/tests/file_stream.rs b/codex-rs/exec-server/tests/file_stream.rs new file mode 100644 index 000000000000..e30e8c468902 --- /dev/null +++ b/codex-rs/exec-server/tests/file_stream.rs @@ -0,0 +1,396 @@ +mod common; + +use anyhow::Result; +use codex_exec_server::Environment; +use codex_exec_server::ExecServerClient; +use codex_exec_server::ExecServerError; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::FileSystemSandboxContext; +use codex_exec_server::FsCloseParams; +use codex_exec_server::FsOpenParams; +use codex_exec_server::FsReadBlockParams; +use codex_exec_server::FsReadBlockResponse; +use codex_exec_server::RemoteExecServerConnectArgs; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use futures::TryStreamExt; +use pretty_assertions::assert_eq; +use std::sync::Arc; +#[cfg(any(unix, windows))] +use std::time::Duration; +use tempfile::TempDir; +#[cfg(windows)] +use tokio::net::windows::named_pipe::ServerOptions; +#[cfg(any(unix, windows))] +use tokio::time::timeout; +use uuid::Uuid; + +use crate::common::exec_server::exec_server; + +const BLOCK_SIZE: usize = 1024 * 1024; +const OPEN_FILE_LIMIT: usize = 128; + +#[tokio::test] +async fn stream_stops_after_an_exact_block_boundary() -> Result<()> { + let server = exec_server().await?; + let file_system = connect_file_system(server.websocket_url())?; + let tmp = TempDir::new()?; + let path = tmp.path().join("exact-blocks.bin"); + std::fs::write(&path, vec![b'x'; BLOCK_SIZE * 2])?; + + let chunks = file_system + .read_file_stream(&PathUri::from_path(path)?, /*sandbox*/ None) + .await? + .try_collect::>() + .await?; + + assert_eq!( + chunks.iter().map(bytes::Bytes::len).collect::>(), + vec![BLOCK_SIZE, BLOCK_SIZE] + ); + Ok(()) +} + +#[tokio::test] +async fn completed_streams_release_handle_capacity() -> Result<()> { + let server = exec_server().await?; + let file_system = connect_file_system(server.websocket_url())?; + let tmp = TempDir::new()?; + let path = tmp.path().join("repeated.txt"); + std::fs::write(&path, b"repeated")?; + let path = PathUri::from_path(path)?; + + for _ in 0..=OPEN_FILE_LIMIT { + let chunks = file_system + .read_file_stream(&path, /*sandbox*/ None) + .await? + .try_collect::>() + .await?; + assert_eq!(chunks, vec![bytes::Bytes::from_static(b"repeated")]); + } + + Ok(()) +} + +#[tokio::test] +async fn stream_rejects_platform_sandbox() -> Result<()> { + let server = exec_server().await?; + let file_system = connect_file_system(server.websocket_url())?; + let tmp = TempDir::new()?; + let path = tmp.path().join("sandboxed.txt"); + std::fs::write(&path, "sandboxed hello")?; + + let result = file_system + .read_file_stream( + &PathUri::from_path(&path)?, + Some(&read_only_sandbox(tmp.path().to_path_buf())), + ) + .await; + + let Err(error) = result else { + panic!("sandboxed stream should be rejected"); + }; + assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); + assert_eq!( + error.to_string(), + "streaming file reads do not support platform sandboxing" + ); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn file_reads_reject_fifo_without_waiting_for_a_writer() -> Result<()> { + let server = exec_server().await?; + let file_system = connect_file_system(server.websocket_url())?; + let tmp = TempDir::new()?; + let path = tmp.path().join("named-pipe"); + let output = std::process::Command::new("mkfifo").arg(&path).output()?; + if !output.status.success() { + anyhow::bail!( + "mkfifo failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let path_uri = PathUri::from_path(&path)?; + let read_error = timeout( + Duration::from_secs(1), + file_system.read_file(&path_uri, /*sandbox*/ None), + ) + .await + .expect("reading a FIFO should not wait for a writer") + .expect_err("reading a FIFO should be rejected"); + let stream_result = timeout( + Duration::from_secs(1), + file_system.read_file_stream(&path_uri, /*sandbox*/ None), + ) + .await + .expect("streaming a FIFO should not wait for a writer"); + let Err(stream_error) = stream_result else { + panic!("streaming a FIFO should be rejected"); + }; + let expected = format!("path `{}` is not a file", path.display()); + assert_eq!( + (read_error.to_string(), stream_error.to_string()), + (expected.clone(), expected) + ); + Ok(()) +} + +#[cfg(windows)] +#[tokio::test] +async fn file_reads_reject_named_pipes() -> Result<()> { + let server = exec_server().await?; + let file_system = connect_file_system(server.websocket_url())?; + + let read_path = format!(r"\\.\pipe\codex-fs-read-{}", Uuid::new_v4()); + let _read_pipe = ServerOptions::new() + .first_pipe_instance(true) + .create(&read_path)?; + let read_error = timeout( + Duration::from_secs(1), + file_system.read_file( + &PathUri::from_path(std::path::Path::new(&read_path))?, + /*sandbox*/ None, + ), + ) + .await + .expect("reading a named pipe should not hang") + .expect_err("reading a named pipe should be rejected"); + + let stream_path = format!(r"\\.\pipe\codex-fs-stream-{}", Uuid::new_v4()); + let _stream_pipe = ServerOptions::new() + .first_pipe_instance(true) + .create(&stream_path)?; + let stream_result = timeout( + Duration::from_secs(1), + file_system.read_file_stream( + &PathUri::from_path(std::path::Path::new(&stream_path))?, + /*sandbox*/ None, + ), + ) + .await + .expect("streaming a named pipe should not hang"); + let Err(stream_error) = stream_result else { + panic!("streaming a named pipe should be rejected"); + }; + + assert_eq!( + (read_error.kind(), stream_error.kind()), + ( + std::io::ErrorKind::InvalidInput, + std::io::ErrorKind::InvalidInput, + ) + ); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn stream_keeps_reading_the_open_file_after_path_replacement() -> Result<()> { + let server = exec_server().await?; + let file_system = connect_file_system(server.websocket_url())?; + let tmp = TempDir::new()?; + 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) + .await?; + + assert_eq!( + stream.try_next().await?, + Some(bytes::Bytes::from(vec![b'a'; BLOCK_SIZE])) + ); + let replacement = tmp.path().join("replacement.bin"); + std::fs::write(&replacement, vec![b'b'; BLOCK_SIZE + 1])?; + std::fs::remove_file(&path)?; + std::fs::rename(replacement, &path)?; + + assert_eq!( + stream.try_next().await?, + Some(bytes::Bytes::from_static(b"a")) + ); + assert_eq!(stream.try_next().await?, None); + Ok(()) +} + +#[tokio::test] +async fn read_block_supports_non_sequential_offsets_and_lengths() -> Result<()> { + let mut server = exec_server().await?; + let client = ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new( + server.websocket_url().to_string(), + "file-stream-protocol-test".to_string(), + )) + .await?; + let tmp = TempDir::new()?; + let path = tmp.path().join("non-sequential.bin"); + std::fs::write(&path, b"0123456789")?; + let open = client + .fs_open(FsOpenParams { + handle_id: Uuid::new_v4().simple().to_string(), + path: PathUri::from_path(path)?, + sandbox: None, + }) + .await?; + + let mut blocks = Vec::new(); + for (offset, len) in [(6, 3), (1, 2), (8, 4), (0, 2)] { + blocks.push( + client + .fs_read_block(FsReadBlockParams { + handle_id: open.handle_id.clone(), + offset, + len, + }) + .await?, + ); + } + assert_eq!( + blocks, + vec![ + FsReadBlockResponse { + chunk: b"678".to_vec().into(), + eof: false, + }, + FsReadBlockResponse { + chunk: b"12".to_vec().into(), + eof: false, + }, + FsReadBlockResponse { + chunk: b"89".to_vec().into(), + eof: true, + }, + FsReadBlockResponse { + chunk: b"01".to_vec().into(), + eof: false, + }, + ] + ); + client + .fs_close(FsCloseParams { + handle_id: open.handle_id, + }) + .await?; + drop(client); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn open_enforces_the_per_connection_limit_and_close_releases_capacity() -> Result<()> { + let mut server = exec_server().await?; + let client = ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new( + server.websocket_url().to_string(), + "file-stream-protocol-test".to_string(), + )) + .await?; + let tmp = TempDir::new()?; + let path = tmp.path().join("limited.bin"); + std::fs::write(&path, b"limited")?; + let path = PathUri::from_path(path)?; + let mut handles = Vec::with_capacity(OPEN_FILE_LIMIT); + for _ in 0..OPEN_FILE_LIMIT { + let open = client + .fs_open(FsOpenParams { + handle_id: Uuid::new_v4().simple().to_string(), + path: path.clone(), + sandbox: None, + }) + .await?; + handles.push(open.handle_id); + } + + let error = client + .fs_open(FsOpenParams { + handle_id: Uuid::new_v4().simple().to_string(), + path: path.clone(), + sandbox: None, + }) + .await + .expect_err("opening beyond the limit should fail"); + let ExecServerError::Server { code, message } = error else { + anyhow::bail!("expected server error, got {error:?}"); + }; + assert_eq!( + (code, message), + ( + -32600, + format!("at most {OPEN_FILE_LIMIT} file reads may be open per connection"), + ) + ); + + client + .fs_close(FsCloseParams { + handle_id: handles.remove(0), + }) + .await?; + client + .fs_open(FsOpenParams { + handle_id: Uuid::new_v4().simple().to_string(), + path, + sandbox: None, + }) + .await?; + drop(client); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn open_rejects_handle_ids_longer_than_32_bytes() -> Result<()> { + let server = exec_server().await?; + let client = ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new( + server.websocket_url().to_string(), + "file-stream-protocol-test".to_string(), + )) + .await?; + let tmp = TempDir::new()?; + let path = tmp.path().join("handle-id-limit.bin"); + std::fs::write(&path, b"limited")?; + + let error = client + .fs_open(FsOpenParams { + handle_id: "x".repeat(33), + path: PathUri::from_path(path)?, + sandbox: None, + }) + .await + .expect_err("oversized handle ID should fail"); + + let ExecServerError::Server { code, message } = error else { + anyhow::bail!("expected server error, got {error:?}"); + }; + assert_eq!( + (code, message), + ( + -32600, + "file read handle ID must not exceed 32 bytes".to_string(), + ) + ); + Ok(()) +} + +fn connect_file_system(websocket_url: &str) -> Result> { + let environment = Environment::create_for_tests(Some(websocket_url.to_string()))?; + Ok(environment.get_filesystem()) +} + +fn read_only_sandbox(path: std::path::PathBuf) -> FileSystemSandboxContext { + let path = AbsolutePathBuf::from_absolute_path(&path) + .unwrap_or_else(|err| panic!("sandbox path should be absolute: {err}")); + FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + }]), + NetworkSandboxPolicy::Restricted, + )) +} diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index 8b167ea5173e..ea4c2fa50f22 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -2,6 +2,7 @@ use anyhow::Context; use anyhow::Result; use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::FILE_READ_CHUNK_SIZE; use codex_exec_server::FileMetadata; use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::RemoveOptions; @@ -11,6 +12,7 @@ use codex_protocol::models::PermissionProfile; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; use codex_utils_path_uri::PathUri; +use futures::TryStreamExt; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -194,6 +196,45 @@ async fn file_system_read_file_returns_bytes( Ok(()) } +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_read_file_stream_returns_bounded_chunks( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let file_path = tmp.path().join("blocks.bin"); + let contents = (0..FILE_READ_CHUNK_SIZE * 2 + 17) + .map(|index| (index % 251) as u8) + .collect::>(); + std::fs::write(&file_path, &contents)?; + + let chunks = file_system + .read_file_stream(&PathUri::from_path(file_path)?, /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))? + .try_collect::>() + .await?; + + assert!( + chunks + .iter() + .all(|chunk| !chunk.is_empty() && chunk.len() <= FILE_READ_CHUNK_SIZE) + ); + assert_eq!( + chunks + .iter() + .flat_map(|chunk| chunk.iter().copied()) + .collect::>(), + contents + ); + + Ok(()) +} + #[test_case(FileSystemImplementation::Local ; "local")] #[test_case(FileSystemImplementation::Remote ; "remote")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/exec-server/tests/file_system_windows.rs b/codex-rs/exec-server/tests/file_system_windows.rs index 9dd25343a185..fbf8b0733073 100644 --- a/codex-rs/exec-server/tests/file_system_windows.rs +++ b/codex-rs/exec-server/tests/file_system_windows.rs @@ -12,9 +12,14 @@ use std::path::Path; use std::process::Command; use anyhow::Result; +use codex_exec_server::FileSystemSandboxContext; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::PathUri; use test_case::test_case; use crate::support::FileSystemImplementation; +use crate::support::create_file_system_context; fn create_directory_junction(target: &Path, alias: &Path) -> Result<()> { let output = Command::new("cmd") @@ -54,3 +59,58 @@ async fn file_system_sandboxed_canonicalize_resolves_directory_junction( ) .await } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> Result<()> { + let context = create_file_system_context(FileSystemImplementation::Remote).await?; + let file_system = context.file_system; + let tmp = tempfile::TempDir::new()?; + let readonly_dir = tmp.path().join("readonly"); + std::fs::create_dir_all(&readonly_dir)?; + + let mut sandbox = read_only_sandbox_for_cwd(readonly_dir.clone())?; + sandbox.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken; + + 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)) + .await; + // Some local Windows hosts cannot create restricted tokens. Reaching that + // error still proves the remote fs helper went through the Windows sandbox + // launcher; before the wrapper fix this read would have run unsandboxed. + if is_unsupported_restricted_token_host(&read_result) { + return Ok(()); + } + assert_eq!(read_result?, b"readable"); + + let blocked_file = readonly_dir.join("blocked.txt"); + let error = file_system + .write_file( + &PathUri::from_path(&blocked_file)?, + b"blocked".to_vec(), + Some(&sandbox), + ) + .await + .expect_err("write outside the sandbox should fail"); + assert!( + !blocked_file.exists(), + "sandboxed fs helper must not create blocked file after error: {error}" + ); + + Ok(()) +} + +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)?, + )?) +} + +fn is_unsupported_restricted_token_host(result: &std::io::Result) -> bool { + result.as_ref().err().is_some_and(|err| { + err.to_string() + .contains("windows sandbox failed: CreateRestrictedToken failed: 87") + }) +} diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 334e44922417..ba7f64a61058 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"), } ); @@ -144,7 +144,8 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re "process/write", serde_json::json!({ "processId": "proc-default-stdin", - "chunk": "aWdub3JlZAo=" + "chunk": "aWdub3JlZAo=", + "writeId": "write-default-stdin" }), ) .await?; @@ -171,6 +172,140 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_dedupes_retried_process_write_ids() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let initialize_id = server + .send_request( + "initialize", + serde_json::to_value(InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + })?, + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &initialize_id + ) + }) + .await?; + + server + .send_notification("initialized", serde_json::json!({})) + .await?; + + let process_start_id = server + .send_request( + "process/start", + serde_json::json!({ + "processId": "proc-write-id", + "argv": [ + "/bin/sh", + "-c", + "IFS= read -r first; printf 'line:%s\\n' \"$first\"; IFS= read -r second; printf 'line:%s\\n' \"$second\"" + ], + "cwd": std::env::current_dir()?, + "env": {}, + "tty": false, + "pipeStdin": true, + "arg0": null + }), + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &process_start_id + ) + }) + .await?; + + for (write_id, chunk) in [ + ("write-1", "Zmlyc3QK"), + ("write-1", "Zmlyc3QK"), + ("write-2", "c2Vjb25kCg=="), + ] { + let request_id = server + .send_request( + "process/write", + serde_json::json!({ + "processId": "proc-write-id", + "chunk": chunk, + "writeId": write_id + }), + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &request_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/write response"); + }; + let write_response: WriteResponse = serde_json::from_value(result)?; + assert_eq!( + write_response, + WriteResponse { + status: WriteStatus::Accepted + } + ); + } + + let mut after_seq = None; + let mut output = Vec::new(); + for _ in 0..5 { + let read_id = server + .send_request( + "process/read", + serde_json::json!({ + "processId": "proc-write-id", + "afterSeq": after_seq, + "maxBytes": null, + "waitMs": 1000 + }), + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &read_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/read response"); + }; + let read_response: ReadResponse = serde_json::from_value(result)?; + output.extend( + read_response + .chunks + .into_iter() + .flat_map(|chunk| chunk.chunk.into_inner()), + ); + after_seq = Some(read_response.next_seq.saturating_sub(1)); + if read_response.closed || output.ends_with(b"line:second\n") { + break; + } + } + + assert_eq!( + String::from_utf8(output)?, + "line:first\nline:second\n".to_string() + ); + + server.shutdown().await?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_server_resumes_detached_session_without_killing_processes() -> anyhow::Result<()> { let mut server = exec_server().await?; diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 114fe122a257..c9f873c158f7 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -6,8 +6,6 @@ mod relay_proto; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::Context; @@ -15,25 +13,20 @@ use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; use codex_api::AuthProvider; -use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecParams; use codex_exec_server::ExecResponse; use codex_exec_server::ExecServerClient; -use codex_exec_server::ExecServerError; use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::FsReadFileParams; use codex_exec_server::NoiseChannelIdentity; use codex_exec_server::NoiseChannelPublicKey; use codex_exec_server::NoiseRendezvousConnectArgs; use codex_exec_server::NoiseRendezvousConnectBundle; -use codex_exec_server::NoiseRendezvousConnectProvider; use codex_exec_server::ProcessId; use codex_exec_server::RemoteEnvironmentConfig; use codex_utils_path_uri::PathUri; -use futures::FutureExt; use futures::SinkExt; use futures::StreamExt; -use futures::future::BoxFuture; use http::HeaderMap; use http::HeaderValue; use pretty_assertions::assert_eq; @@ -72,68 +65,10 @@ impl AuthProvider for StaticRegistryAuthProvider { } } -struct FailingNoiseConnectProvider { - attempts: Arc, -} - -impl NoiseRendezvousConnectProvider for FailingNoiseConnectProvider { - fn connect_bundle( - &self, - _: NoiseChannelPublicKey, - ) -> BoxFuture<'_, Result> { - self.attempts.fetch_add(1, Ordering::SeqCst); - async { - Err(ExecServerError::Protocol( - "test registry connect failure".to_string(), - )) - } - .boxed() - } -} - fn static_registry_auth_provider() -> codex_api::SharedAuthProvider { Arc::new(StaticRegistryAuthProvider) } -#[tokio::test] -async fn noise_environment_refreshes_bundle_for_each_connection_attempt() -> Result<()> { - let attempts = Arc::new(AtomicUsize::new(0)); - let manager = EnvironmentManager::without_environments(); - manager.upsert_noise_environment( - ENVIRONMENT_ID.to_string(), - Arc::new(FailingNoiseConnectProvider { - attempts: Arc::clone(&attempts), - }), - )?; - let backend = manager - .get_environment(ENVIRONMENT_ID) - .context("Noise environment should be materialized")? - .get_exec_backend(); - - for attempt in 1..=2 { - let result = backend - .start(ExecParams { - process_id: ProcessId::new(format!("proc-{attempt}")), - argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir()?)?, - env_policy: None, - env: HashMap::new(), - tty: false, - pipe_stdin: false, - arg0: None, - }) - .await; - assert!(matches!( - result, - Err(ExecServerError::Protocol(ref message)) - if message == "test registry connect failure" - )); - } - - assert_eq!(attempts.load(Ordering::SeqCst), 2); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -215,12 +150,14 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { 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"), } ); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 60667bb5dfaf..a667cdd53d7d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -68,10 +68,9 @@ impl EventProcessorWithHumanOutput { match item { ThreadItem::CommandExecution { command, cwd, .. } => { eprintln!( - "{}\n{} in {}", + "{}\n{} in {cwd}", "exec".style(self.italic).style(self.magenta), command.style(self.bold), - cwd.display() ); } ThreadItem::McpToolCall { server, tool, .. } => { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index f8521071e3ee..83cd6d3c6bf0 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -98,6 +98,7 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { tool: "web_run".to_string(), status: McpToolCallStatus::Completed, arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(codex_app_server_protocol::McpToolCallResult { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 72e24d5cb602..f52602a2ca80 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -67,6 +67,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; @@ -350,6 +351,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, @@ -358,6 +367,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(); @@ -469,6 +479,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, @@ -476,6 +487,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 { @@ -556,6 +568,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result client_name: "codex_exec".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }; @@ -891,6 +904,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { personality: None, output_schema, collaboration_mode: None, + multi_agent_mode: None, }, }, "turn/start", @@ -1443,7 +1457,7 @@ async fn parse_latest_turn_context_cwd(path: &Path) -> Option { continue; }; if let RolloutItem::TurnContext(item) = rollout_line.item { - return Some(item.cwd); + return Some(item.cwd.into_path_buf()); } } None @@ -1739,6 +1753,15 @@ async fn handle_server_request( ) .await } + ServerRequest::CurrentTimeRead { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "external current time is not supported in exec mode".to_string(), + ) + .await + } ServerRequest::ApplyPatchApproval { request_id, params } => { reject_server_request( client, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index c4502ee60d11..34fd578d12fe 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -372,6 +372,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), @@ -790,6 +791,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(PathBuf::from("/tmp/rollout.jsonl")), cwd: test_path_buf("/tmp").abs(), @@ -818,5 +820,6 @@ fn sample_thread_start_response() -> ThreadStartResponse { }, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), } } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2cee9b0e83e6..1f44ca31a8cb 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -170,7 +170,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { let command_item = ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -210,7 +210,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { item: ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, @@ -478,6 +478,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { tool: "tool_x".to_string(), status: ApiMcpToolCallStatus::InProgress, arguments: json!({ "key": "value" }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -496,6 +497,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { tool: "tool_x".to_string(), status: ApiMcpToolCallStatus::Completed, arguments: json!({ "key": "value" }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(McpToolCallResult { @@ -568,6 +570,7 @@ fn mcp_tool_call_failure_sets_failed_status() { tool: "tool_y".to_string(), status: ApiMcpToolCallStatus::Failed, arguments: json!({ "param": 42 }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -617,6 +620,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { tool: "tool_z".to_string(), status: ApiMcpToolCallStatus::InProgress, arguments: serde_json::Value::Null, + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -635,6 +639,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { tool: "tool_z".to_string(), status: ApiMcpToolCallStatus::Completed, arguments: serde_json::Value::Null, + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(McpToolCallResult { @@ -1321,7 +1326,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { item: ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -1361,7 +1366,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { items: vec![ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 4b8cecc515fe..03976a8546bb 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -36,6 +36,7 @@ async fn spawn_command_under_sandbox( capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -60,7 +61,12 @@ async fn spawn_command_under_sandbox( child.arg0(arg0); } child.args(args); - child.current_dir(exec_request.cwd); + // TODO(anp): Keep PathUri through the macOS sandbox process launch boundary. + let native_cwd = exec_request + .cwd + .to_abs_path() + .map_err(|err| io::Error::other(err.to_string()))?; + child.current_dir(native_cwd); child.env_clear(); child.envs(exec_request.env); diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions.rs b/codex-rs/ext/extension-api/examples/enabled_extensions.rs index 9027c2824176..45b178bc8131 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions.rs @@ -74,7 +74,11 @@ async fn contribute_prompt( ) -> Vec { let mut fragments = Vec::new(); for contributor in registry.context_contributors() { - fragments.extend(contributor.contribute(session_store, thread_store).await); + fragments.extend( + contributor + .contribute_thread_context(session_store, thread_store) + .await, + ); } fragments } diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs index 531f65b99eb4..414a67215bb8 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs @@ -17,7 +17,7 @@ pub fn install(registry: &mut ExtensionRegistryBuilder<()>) { struct StyleContributor; impl ContextContributor for StyleContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, @@ -37,7 +37,7 @@ impl ContextContributor for StyleContributor { struct UsageContributor; impl ContextContributor for UsageContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 3a0fee49a96f..7d16efd3d4a8 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -11,6 +11,7 @@ use codex_tools::ToolExecutor; use crate::ExtensionData; +mod context; mod mcp; mod prompt; mod thread_lifecycle; @@ -18,6 +19,7 @@ mod tool_lifecycle; mod turn_input; mod turn_lifecycle; +pub use context::TurnContextContributionInput; pub use mcp::McpServerContribution; pub use mcp::McpServerContributionContext; pub use prompt::PromptFragment; @@ -62,12 +64,34 @@ pub trait McpServerContributor: Send + Sync { } /// Extension contribution that adds prompt fragments during prompt assembly. +/// +/// Implementations should use the method matching the scope needed by the +/// fragment: thread/session context for stable inputs, and turn context for +/// fragments that depend on turn-local host state. pub trait ContextContributor: Send + Sync { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, - ) -> ExtensionFuture<'a, Vec>; + ) -> ExtensionFuture<'a, Vec> { + Box::pin(async move { + let _self = self; + let _session_store = session_store; + let _thread_store = thread_store; + Vec::new() + }) + } + + fn contribute_turn_context<'a>( + &'a self, + input: TurnContextContributionInput<'a>, + ) -> ExtensionFuture<'a, Vec> { + Box::pin(async move { + let _self = self; + let _input = input; + Vec::new() + }) + } } /// Contributor for host-owned thread lifecycle gates. diff --git a/codex-rs/ext/extension-api/src/contributors/context.rs b/codex-rs/ext/extension-api/src/contributors/context.rs new file mode 100644 index 000000000000..fb6239ec4b43 --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/context.rs @@ -0,0 +1,20 @@ +use codex_protocol::ThreadId; + +use crate::ExtensionData; + +/// Host context available while extensions contribute turn-scoped context fragments. +#[derive(Clone, Copy)] +pub struct TurnContextContributionInput<'a> { + /// Stable host-owned thread identifier. + pub thread_id: ThreadId, + /// Stable host-owned turn identifier. + pub turn_id: &'a str, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn. + pub turn_store: &'a ExtensionData, + /// Effective model context window for this turn, when known. + pub model_context_window: Option, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index e646a53a5265..de0e0c99f826 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -54,6 +54,7 @@ pub use contributors::ToolLifecycleContributor; pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; +pub use contributors::TurnContextContributionInput; pub use contributors::TurnErrorInput; pub use contributors::TurnInputContext; pub use contributors::TurnInputContributor; diff --git a/codex-rs/ext/extension-api/tests/registry.rs b/codex-rs/ext/extension-api/tests/registry.rs index 79773a98cf98..59b4f8c5e400 100644 --- a/codex-rs/ext/extension-api/tests/registry.rs +++ b/codex-rs/ext/extension-api/tests/registry.rs @@ -12,12 +12,14 @@ use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionFuture; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; +use codex_extension_api::PromptSlot; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::TokenUsageContributor; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; use codex_extension_api::ToolExecutor; use codex_extension_api::ToolLifecycleContributor; +use codex_extension_api::TurnContextContributionInput; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputContributor; use codex_extension_api::TurnItemContributor; @@ -34,7 +36,7 @@ use pretty_assertions::assert_eq; struct AllContributors; impl ContextContributor for AllContributors { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, @@ -147,7 +149,7 @@ async fn build_round_trips_every_contributor_category() { struct NamedContextContributor(&'static str); impl ContextContributor for NamedContextContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, @@ -158,6 +160,20 @@ impl ContextContributor for NamedContextContributor { } } +struct NamedTurnContextContributor(&'static str); + +impl ContextContributor for NamedTurnContextContributor { + fn contribute_turn_context<'a>( + &'a self, + _input: TurnContextContributionInput<'a>, + ) -> ExtensionFuture<'a, Vec> { + Box::pin(std::future::ready(vec![PromptFragment::new( + PromptSlot::ContextualUser, + self.0, + )])) + } +} + struct RecordingTurnItemContributor { name: &'static str, calls: Arc>>, @@ -186,6 +202,8 @@ async fn contributors_preserve_registration_order() { let mut builder = ExtensionRegistryBuilder::<()>::new(); builder.prompt_contributor(Arc::new(NamedContextContributor("first"))); builder.prompt_contributor(Arc::new(NamedContextContributor("second"))); + builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-first"))); + builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-second"))); for name in ["first", "second"] { builder.turn_item_contributor(Arc::new(RecordingTurnItemContributor { name, @@ -199,7 +217,25 @@ async fn contributors_preserve_registration_order() { let mut fragments = Vec::new(); for contributor in registry.context_contributors() { - fragments.extend(contributor.contribute(&session_store, &thread_store).await); + fragments.extend( + contributor + .contribute_thread_context(&session_store, &thread_store) + .await, + ); + } + for contributor in registry.context_contributors() { + fragments.extend( + contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: codex_protocol::ThreadId::default(), + turn_id: turn_store.level_id(), + session_store: &session_store, + thread_store: &thread_store, + turn_store: &turn_store, + model_context_window: Some(123), + }) + .await, + ); } let mut item = TurnItem::HookPrompt(HookPromptItem { id: "item".to_string(), @@ -217,6 +253,8 @@ async fn contributors_preserve_registration_order() { vec![ PromptFragment::developer_policy("first"), PromptFragment::developer_policy("second"), + PromptFragment::new(PromptSlot::ContextualUser, "turn-first"), + PromptFragment::new(PromptSlot::ContextualUser, "turn-second"), ] ); assert_eq!( diff --git a/codex-rs/ext/goal/src/api.rs b/codex-rs/ext/goal/src/api.rs index a4dc93409ee8..807f481d0d49 100644 --- a/codex-rs/ext/goal/src/api.rs +++ b/codex-rs/ext/goal/src/api.rs @@ -6,8 +6,11 @@ use std::sync::PoisonError; use std::sync::Weak; use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::ThreadGoalUpdatedEvent; use codex_protocol::protocol::validate_thread_goal_objective; use crate::runtime::GoalRuntimeHandle; @@ -61,6 +64,14 @@ pub struct GoalSetOutcome { } impl GoalSetOutcome { + pub fn thread_goal_updated_item(&self) -> RolloutItem { + RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.goal.thread_id, + turn_id: None, + goal: self.goal.clone(), + })) + } + pub async fn apply_runtime_effects(&self, goal_service: &GoalService) { if let Some(runtime) = goal_service.runtime_for_thread(self.goal.thread_id) && let Err(err) = runtime diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 449c7beb09ad..fe915746f9a7 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,12 +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, @@ -97,25 +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: "generated-call".to_string(), + 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, }, ]; @@ -203,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/mcp/src/executor_plugin/provider.rs b/codex-rs/ext/mcp/src/executor_plugin/provider.rs index bbba63748ba1..e9d5f803db58 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider.rs @@ -7,6 +7,7 @@ use codex_mcp::parse_plugin_mcp_config; use codex_plugin::PluginResourceLocator; use codex_plugin::ResolvedPlugin; use codex_plugin::ResolvedPluginLocation; +use codex_plugin::manifest::PluginManifestMcpServers; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::io; @@ -56,26 +57,47 @@ async fn load_from_file_system( ) -> Result, ExecutorPluginMcpProviderError> { let ResolvedPluginLocation::Environment { environment_id, .. } = plugin.location(); let plugin_id = plugin.selected_root_id(); - let (config_path, is_default) = match plugin.manifest().paths.mcp_servers.as_ref() { - Some(PluginResourceLocator::Environment { path, .. }) => (path.clone(), false), - None => (plugin_root.join(DEFAULT_MCP_CONFIG_FILE), true), - }; - let config_uri = PathUri::from_abs_path(&config_path); - - let contents = match file_system - .read_file_text(&config_uri, /*sandbox*/ None) - .await - { - Ok(contents) => contents, - Err(source) if is_default && source.kind() == io::ErrorKind::NotFound => { - return Ok(Vec::new()); + let (contents, config_path) = match plugin.manifest().paths.mcp_servers.as_ref() { + Some(PluginManifestMcpServers::Path(PluginResourceLocator::Environment { + path, .. + })) => { + let config_uri = PathUri::from_abs_path(path); + ( + file_system + .read_file_text(&config_uri, /*sandbox*/ None) + .await + .map_err(|source| ExecutorPluginMcpProviderError::ReadConfig { + plugin_id: plugin_id.to_string(), + path: path.clone(), + source, + })?, + path.clone(), + ) } - Err(source) => { - return Err(ExecutorPluginMcpProviderError::ReadConfig { - plugin_id: plugin_id.to_string(), - path: config_path.clone(), - source, - }); + Some(PluginManifestMcpServers::Object(object_config)) => ( + object_config.clone(), + plugin_root.join(".codex-plugin/plugin.json"), + ), + None => { + let config_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE); + let config_uri = PathUri::from_abs_path(&config_path); + let contents = match file_system + .read_file_text(&config_uri, /*sandbox*/ None) + .await + { + Ok(contents) => contents, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(source) => { + return Err(ExecutorPluginMcpProviderError::ReadConfig { + plugin_id: plugin_id.to_string(), + path: config_path.clone(), + source, + }); + } + }; + (contents, config_path) } }; let parsed = parse_plugin_mcp_config( 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 dcaac40b698b..b154b060b2b1 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -8,12 +8,14 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::ExecutorFileSystemFuture; use codex_exec_server::FileMetadata; +use codex_exec_server::FileSystemReadStream; use codex_exec_server::FileSystemResult; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::RemoveOptions; use codex_plugin::ResolvedPlugin; 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::PathUri; @@ -73,6 +75,14 @@ impl ExecutorFileSystem for SyntheticExecutorFileSystem { }) } + fn read_file_stream<'a>( + &'a self, + _path: &'a PathUri, + _sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(async { Self::unsupported() }) + } + fn write_file<'a>( &'a self, _path: &'a PathUri, @@ -135,7 +145,10 @@ async fn reads_declared_config_only_through_executor_file_system() { .expect("absolute plugin root"); assert!(!plugin_root.as_path().exists()); let config_path = plugin_root.join("config/mcp.json"); - let plugin = resolved_plugin(&plugin_root, Some(config_path.clone())); + let plugin = resolved_plugin( + &plugin_root, + Some(PluginManifestMcpServers::Path(config_path.clone())), + ); let file_system = SyntheticExecutorFileSystem { config_path: config_path.clone(), config_contents: Some(MCP_CONFIG_CONTENTS), @@ -178,6 +191,60 @@ async fn reads_declared_config_only_through_executor_file_system() { assert_eq!(reads(&file_system), vec![config_path]); } +#[tokio::test] +async fn reads_manifest_object_config_without_executor_file_system_access() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let plugin_root = AbsolutePathBuf::from_absolute_path_checked(temp_dir.path().join("plugin")) + .expect("absolute plugin root"); + let config_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE); + let plugin = resolved_plugin( + &plugin_root, + Some(PluginManifestMcpServers::Object( + r#"{"counter":{"command":"counter-mcp","environment_id":"local"}}"#.to_string(), + )), + ); + let file_system = SyntheticExecutorFileSystem { + config_path, + config_contents: None, + reads: Mutex::new(Vec::new()), + }; + + let servers = load_from_file_system(&plugin, &plugin_root, &file_system) + .await + .expect("load manifest object executor MCP config"); + + assert_eq!( + servers, + vec![( + "counter".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "counter-mcp".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: Some(plugin_root.to_path_buf()), + }, + environment_id: "executor-test".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + 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(), + }, + )] + ); + assert_eq!(reads(&file_system), Vec::new()); +} + #[tokio::test] async fn missing_default_config_is_empty() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -205,7 +272,10 @@ async fn malformed_declared_config_is_an_error() { let plugin_root = AbsolutePathBuf::from_absolute_path_checked(temp_dir.path().join("plugin")) .expect("absolute plugin root"); let config_path = plugin_root.join("mcp.json"); - let plugin = resolved_plugin(&plugin_root, Some(config_path.clone())); + let plugin = resolved_plugin( + &plugin_root, + Some(PluginManifestMcpServers::Path(config_path.clone())), + ); let file_system = SyntheticExecutorFileSystem { config_path: config_path.clone(), config_contents: Some("{not-json"), @@ -233,7 +303,7 @@ async fn malformed_declared_config_is_an_error() { fn resolved_plugin( plugin_root: &AbsolutePathBuf, - mcp_servers: Option, + mcp_servers: Option>, ) -> ResolvedPlugin { ResolvedPlugin::from_environment( "selected-root".to_string(), @@ -246,7 +316,7 @@ fn resolved_plugin( description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: None, + skills: Vec::new(), mcp_servers, apps: None, hooks: None, diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 23c80a8779df..464ce24576c2 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -48,7 +48,7 @@ impl MemoriesExtensionConfig { } impl ContextContributor for MemoriesExtension { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index b78c20c9f1b4..9cc038b800c5 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -181,7 +181,7 @@ async fn prompt_contribution_uses_memory_summary_when_enabled() { }); let fragments = extension - .contribute(&ExtensionData::new("session"), &thread_store) + .contribute_thread_context(&ExtensionData::new("session"), &thread_store) .await; assert_eq!(fragments.len(), 1); diff --git a/codex-rs/ext/skills/src/config.rs b/codex-rs/ext/skills/src/config.rs index e883f0c5f51b..3d903cafb848 100644 --- a/codex-rs/ext/skills/src/config.rs +++ b/codex-rs/ext/skills/src/config.rs @@ -5,4 +5,6 @@ pub struct SkillsExtensionConfig { pub include_instructions: bool, /// Whether bundled skills are eligible for discovery. pub bundled_skills_enabled: bool, + /// Whether orchestrator-owned skills are eligible for discovery. + pub orchestrator_skills_enabled: bool, } diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index bf4d86d36413..de9facd8c2f3 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_extension_api::ConfigContributor; @@ -61,14 +61,14 @@ where .get::>() .map(|selected_roots| selected_roots.as_ref().clone()) .unwrap_or_default(); - let orchestrator_skills_enabled = !input + let orchestrator_skills_available = !input .environments .iter() .any(|environment| environment.environment_id == LOCAL_ENVIRONMENT_ID); input.thread_store.insert(SkillsThreadState::new( (self.config_from_host)(input.config), selected_roots, - orchestrator_skills_enabled, + orchestrator_skills_available, )); }) } @@ -89,11 +89,11 @@ where if let Some(state) = thread_store.get::() { state.set_config(next_config); } else { - let orchestrator_skills_enabled = true; + let orchestrator_skills_available = true; thread_store.insert(SkillsThreadState::new( next_config, Vec::new(), - orchestrator_skills_enabled, + orchestrator_skills_available, )); } } @@ -103,7 +103,7 @@ impl ContextContributor for SkillsExtension where C: Send + Sync + 'static, { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, @@ -121,7 +121,7 @@ where SkillListQuery { turn_id: thread_store.level_id().to_string(), executor_roots: thread_state.selected_roots().to_vec(), - host: None, + host_snapshot: None, include_host_skills: false, include_bundled_skills: config.bundled_skills_enabled, include_orchestrator_skills: thread_state.orchestrator_skills_enabled(), @@ -184,11 +184,11 @@ where }; let config = thread_state.config(); - let host_loaded_skills = turn_store.get::(); + let host_snapshot = turn_store.get::(); let query = SkillListQuery { turn_id: input.turn_id.clone(), executor_roots: thread_state.selected_roots().to_vec(), - host: host_loaded_skills.clone(), + host_snapshot: host_snapshot.clone(), include_host_skills: true, include_bundled_skills: config.bundled_skills_enabled, include_orchestrator_skills: thread_state.orchestrator_skills_enabled(), @@ -217,12 +217,7 @@ where let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default(); for entry in &selected_entries { match self - .read_main_prompt( - entry, - host_loaded_skills.clone(), - session_store, - &thread_state, - ) + .read_main_prompt(entry, host_snapshot.clone(), session_store, &thread_state) .await { Ok(read_result) => { @@ -259,12 +254,12 @@ where } } - if let Some(host_loaded_skills) = &host_loaded_skills { + if let Some(host_snapshot) = &host_snapshot { for entry in selected_entries .iter() .filter(|entry| entry.authority.kind != SkillSourceKind::Host) { - for host_skill in host_loaded_skills + for host_skill in host_snapshot .outcome() .skills .iter() @@ -292,6 +287,7 @@ where } impl SkillsExtension { + #[tracing::instrument(level = "trace", skip_all)] async fn list_skills( &self, mut query: SkillListQuery, @@ -316,10 +312,11 @@ impl SkillsExtension { catalog } + #[tracing::instrument(level = "trace", skip_all, fields(skill = %entry.name))] async fn read_main_prompt( &self, entry: &SkillCatalogEntry, - host_loaded_skills: Option>, + host_snapshot: Option>, session_store: &ExtensionData, thread_state: &SkillsThreadState, ) -> Result { @@ -330,7 +327,7 @@ impl SkillsExtension { authority: entry.authority.clone(), package: entry.id.clone(), resource: entry.main_prompt.clone(), - host: host_loaded_skills, + host_snapshot, mcp_resources: session_store.get::(), }, ) diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index 329c0c5813cd..405592e93478 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -6,7 +6,7 @@ mod executor; mod host; mod orchestrator; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_mcp::McpResourceClient; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -26,7 +26,7 @@ pub use orchestrator::OrchestratorSkillProvider; pub struct SkillListQuery { pub turn_id: String, pub executor_roots: Vec, - pub host: Option>, + pub host_snapshot: Option>, pub include_host_skills: bool, pub include_bundled_skills: bool, pub include_orchestrator_skills: bool, @@ -38,7 +38,7 @@ pub struct SkillReadRequest { pub authority: SkillAuthority, pub package: SkillPackageId, pub resource: SkillResourceId, - pub host: Option>, + pub host_snapshot: Option>, pub mcp_resources: Option>, } diff --git a/codex-rs/ext/skills/src/provider/executor.rs b/codex-rs/ext/skills/src/provider/executor.rs index 2d26ae8bed00..75234dfbee61 100644 --- a/codex-rs/ext/skills/src/provider/executor.rs +++ b/codex-rs/ext/skills/src/provider/executor.rs @@ -76,13 +76,17 @@ impl SkillProvider for ExecutorSkillProvider { }; let file_system = environment.get_filesystem(); let outcome = filter_skill_load_outcome_for_product( - load_skills_from_roots([SkillRoot { - path: root_path.clone(), - scope: SkillScope::User, - file_system: Arc::clone(&file_system), - plugin_id: None, - plugin_root: None, - }]) + load_skills_from_roots( + [SkillRoot { + path: root_path.clone(), + scope: SkillScope::User, + file_system: Arc::clone(&file_system), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await, self.restriction_product, ); diff --git a/codex-rs/ext/skills/src/provider/host.rs b/codex-rs/ext/skills/src/provider/host.rs index 63d0d4588c8b..2cee694216dc 100644 --- a/codex-rs/ext/skills/src/provider/host.rs +++ b/codex-rs/ext/skills/src/provider/host.rs @@ -18,12 +18,10 @@ use crate::provider::SkillSearchRequest; const HOST_AUTHORITY_ID: &str = "host"; -/// Host-owned skill provider backed by the already-loaded turn skills. +/// Host-owned skill provider backed by an immutable service snapshot. /// -/// The provider intentionally does not reload or cache host skills. Core owns -/// skill loading, including plugin roots, runtime extra roots, and the primary -/// environment filesystem. This adapter only maps that loaded outcome into the -/// skills-extension catalog/read contract. +/// Discovery and caching belong to `SkillsService`; this provider only maps a +/// snapshot into the authority-aware catalog/read contract. #[derive(Clone, Default)] pub struct HostSkillProvider; @@ -36,24 +34,24 @@ impl HostSkillProvider { impl SkillProvider for HostSkillProvider { fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { Box::pin(async move { - let Some(host_loaded_skills) = query.host else { + let Some(host_snapshot) = query.host_snapshot else { return Err(SkillProviderError::new( - "host skill provider requires loaded host skills", + "host skill provider requires a host skills snapshot", )); }; - Ok(catalog_from_outcome(host_loaded_skills.outcome())) + Ok(catalog_from_outcome(host_snapshot.outcome())) }) } fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { Box::pin(async move { - let Some(host_loaded_skills) = request.host else { + let Some(host_snapshot) = request.host_snapshot else { return Err(SkillProviderError::new( - "host skill provider requires loaded host skills", + "host skill provider requires a host skills snapshot", )); }; - let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| { + let Some(skill) = host_snapshot.outcome().skills.iter().find(|skill| { let skill_path = skill.path_to_skills_md.to_string_lossy(); skill_path == request.resource.as_str() || skill_path.replace('\\', "/") == request.resource.as_str() @@ -64,15 +62,12 @@ impl SkillProvider for HostSkillProvider { ))); }; - let contents = host_loaded_skills - .read_skill_text(skill) - .await - .map_err(|err| { - SkillProviderError::new(format!( - "failed to read host skill resource {}: {err}", - request.resource.as_str() - )) - })?; + let contents = host_snapshot.read_skill_text(skill).await.map_err(|err| { + SkillProviderError::new(format!( + "failed to read host skill resource {}: {err}", + request.resource.as_str() + )) + })?; Ok(SkillReadResult { resource: request.resource, diff --git a/codex-rs/ext/skills/src/provider/orchestrator.rs b/codex-rs/ext/skills/src/provider/orchestrator.rs index 045b67de62c7..8d97bf32a1da 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 d913e4161cfd..dda32b486409 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,9 +9,16 @@ 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; +#[tracing::instrument( + level = "trace", + skip_all, + fields(catalog_entry_count = catalog.entries.len()) +)] pub(crate) fn available_skills_fragment( catalog: &SkillCatalog, ) -> Option { @@ -26,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); @@ -49,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/selection.rs b/codex-rs/ext/skills/src/selection.rs index cfea2e3abd92..a708b18d6c42 100644 --- a/codex-rs/ext/skills/src/selection.rs +++ b/codex-rs/ext/skills/src/selection.rs @@ -10,6 +10,14 @@ use crate::catalog::SkillPackageId; const SKILL_PATH_PREFIX: &str = "skill://"; +#[tracing::instrument( + level = "trace", + skip_all, + fields( + input_count = inputs.len(), + catalog_entry_count = catalog.entries.len() + ) +)] pub(crate) fn collect_explicit_skill_mentions( inputs: &[UserInput], catalog: &SkillCatalog, diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index f718f6e0291e..0b751ebdc2e2 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -27,7 +27,7 @@ const MAX_CACHED_ORCHESTRATOR_CONTENT_BYTES: usize = 8 * 1024 * 1024; pub(crate) struct SkillsThreadState { config: Mutex, selected_roots: Vec, - orchestrator_skills_enabled: bool, + orchestrator_skills_available: bool, orchestrator_cache: Mutex>>, } @@ -35,12 +35,12 @@ impl SkillsThreadState { pub(crate) fn new( config: SkillsExtensionConfig, selected_roots: Vec, - orchestrator_skills_enabled: bool, + orchestrator_skills_available: bool, ) -> Self { Self { config: Mutex::new(config), selected_roots, - orchestrator_skills_enabled, + orchestrator_skills_available, orchestrator_cache: Mutex::new(None), } } @@ -64,7 +64,7 @@ impl SkillsThreadState { } pub(crate) fn orchestrator_skills_enabled(&self) -> bool { - self.orchestrator_skills_enabled + self.orchestrator_skills_available && self.config().orchestrator_skills_enabled } pub(crate) async fn orchestrator_catalog_snapshot( diff --git a/codex-rs/ext/skills/src/tools/list.rs b/codex-rs/ext/skills/src/tools/list.rs index 31cb68003d69..e556f9783f06 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/src/tools/mod.rs b/codex-rs/ext/skills/src/tools/mod.rs index a1898b1a2a14..0d05064afb89 100644 --- a/codex-rs/ext/skills/src/tools/mod.rs +++ b/codex-rs/ext/skills/src/tools/mod.rs @@ -68,7 +68,7 @@ impl SkillToolContext { self.providers.list_orchestrator_for_turn(SkillListQuery { turn_id: turn_id.to_string(), executor_roots: Vec::new(), - host: None, + host_snapshot: None, include_host_skills: false, include_bundled_skills: false, include_orchestrator_skills: true, diff --git a/codex-rs/ext/skills/src/tools/read.rs b/codex-rs/ext/skills/src/tools/read.rs index 358842aa1e4e..64587cab95b6 100644 --- a/codex-rs/ext/skills/src/tools/read.rs +++ b/codex-rs/ext/skills/src/tools/read.rs @@ -82,7 +82,7 @@ impl ToolExecutor for ReadTool { authority, package: SkillPackageId(args.package), resource: requested_resource.clone(), - host: None, + host_snapshot: None, mcp_resources: self.context.mcp_resources.clone(), }, ) diff --git a/codex-rs/ext/skills/tests/executor_file_system_authority.rs b/codex-rs/ext/skills/tests/executor_file_system_authority.rs index 824300f22a27..168ef0e66627 100644 --- a/codex-rs/ext/skills/tests/executor_file_system_authority.rs +++ b/codex-rs/ext/skills/tests/executor_file_system_authority.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_core_skills::loader::SkillRoot; use codex_core_skills::loader::load_skills_from_roots; use codex_exec_server::CopyOptions; @@ -12,6 +12,7 @@ use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::ExecutorFileSystemFuture; use codex_exec_server::FileMetadata; +use codex_exec_server::FileSystemReadStream; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::RemoveOptions; @@ -111,6 +112,19 @@ impl ExecutorFileSystem for SyntheticFileSystem { Box::pin(SyntheticFileSystem::read_file(self, path)) } + fn read_file_stream<'a>( + &'a self, + _path: &'a PathUri, + _sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> { + Box::pin(async { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "synthetic filesystem does not support streaming reads", + )) + }) + } + fn write_file<'a>( &'a self, _path: &'a PathUri, @@ -176,16 +190,20 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() { assert!(!alias_root.as_path().exists()); assert!(!canonical_root.as_path().exists()); - let outcome = load_skills_from_roots([SkillRoot { - path: alias_root.clone(), - scope: SkillScope::User, - file_system: Arc::new(SyntheticFileSystem { - alias_root, - canonical_root: canonical_root.clone(), - }), - plugin_id: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: alias_root.clone(), + scope: SkillScope::User, + file_system: Arc::new(SyntheticFileSystem { + alias_root, + canonical_root: canonical_root.clone(), + }), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert_eq!(outcome.errors, Vec::new()); assert_eq!(outcome.skills.len(), 1); @@ -196,7 +214,7 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() { skill.path_to_skills_md, canonical_root.join("skill/SKILL.md") ); - let loaded = HostLoadedSkills::new(Arc::new(outcome)); + let loaded = HostSkillsSnapshot::new(Arc::new(outcome)); assert_eq!( loaded.read_skill_text(&skill).await.expect("skill body"), SKILL_CONTENTS @@ -230,7 +248,7 @@ async fn selected_root_id_distinguishes_identical_executor_paths() { }, }) .collect(), - host: None, + host_snapshot: None, include_host_skills: false, include_bundled_skills: true, include_orchestrator_skills: false, diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index bcf058d6b402..8b02558347df 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -4,16 +4,20 @@ use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -use codex_core_skills::HostLoadedSkills; +use codex_core_skills::HostSkillsSnapshot; use codex_core_skills::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS; 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; @@ -52,7 +57,7 @@ const DEMO_SKILL_CONTENTS: &str = "---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n"; #[tokio::test] -async fn installed_extension_uses_host_loaded_skills() -> TestResult { +async fn installed_extension_uses_host_service_snapshot() -> TestResult { let codex_home = test_codex_home(); let skill_path = codex_home.join("skills").join("demo").join("SKILL.md"); std::fs::create_dir_all( @@ -97,7 +102,7 @@ async fn installed_extension_uses_host_loaded_skills() -> TestResult { let loaded_skills = Arc::new(outcome); let skill_prompt_path = skill_path_string.replace('\\', "/"); let turn_store = ExtensionData::new("turn-1"); - turn_store.insert(HostLoadedSkills::new(Arc::clone(&loaded_skills))); + turn_store.insert(HostSkillsSnapshot::new(Arc::clone(&loaded_skills))); let fragments = registry.turn_input_contributors()[0] .contribute( @@ -183,7 +188,7 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in .await; let prompt_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert_eq!(1, prompt_fragments.len()); assert!( @@ -228,7 +233,7 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in read_request_keys(&read_requests) ); let rebuilt_prompt_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert_eq!(1, rebuilt_prompt_fragments.len()); assert!(rebuilt_prompt_fragments[0].text().contains("lint-fix")); @@ -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)); @@ -294,7 +421,7 @@ async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult { .await; let initial_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert!(initial_fragments.is_empty()); let EventMsg::Warning(warning) = event_rx.try_recv()?.msg else { @@ -565,12 +692,14 @@ fn test_entry( struct TestConfig { include_instructions: bool, bundled_skills_enabled: bool, + orchestrator_skills_enabled: bool, } fn default_config() -> TestConfig { TestConfig { include_instructions: true, bundled_skills_enabled: true, + orchestrator_skills_enabled: true, } } @@ -578,6 +707,7 @@ fn skills_extension_config(config: &TestConfig) -> SkillsExtensionConfig { SkillsExtensionConfig { include_instructions: config.include_instructions, bundled_skills_enabled: config.bundled_skills_enabled, + orchestrator_skills_enabled: config.orchestrator_skills_enabled, } } diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 688b504dec15..7afdfe4137ba 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use codex_api::AllowedCaller; use codex_api::ApproximateLocation; +use codex_api::ExternalWebAccess; +use codex_api::ExternalWebAccessMode; use codex_api::LocationType; use codex_api::SearchContextSize; use codex_api::SearchFilters; @@ -73,14 +75,19 @@ fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSet blocked_domains: None, }), allowed_callers: Some(vec![AllowedCaller::Direct]), - external_web_access: Some(match web_search_mode { - WebSearchMode::Live => true, - WebSearchMode::Cached | WebSearchMode::Disabled => false, - }), + external_web_access: Some(external_web_access_for_mode(web_search_mode)), ..Default::default() } } +fn external_web_access_for_mode(web_search_mode: WebSearchMode) -> ExternalWebAccess { + match web_search_mode { + WebSearchMode::Disabled | WebSearchMode::Cached => ExternalWebAccess::Boolean(false), + WebSearchMode::Indexed => ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed), + WebSearchMode::Live => ExternalWebAccess::Boolean(true), + } +} + impl ThreadLifecycleContributor for WebSearchExtension { fn on_thread_start<'a>( &'a self, @@ -149,9 +156,32 @@ mod tests { use super::AuthManager; use super::Config; use super::WebSearchExtensionConfig; + use super::external_web_access_for_mode; use super::install; use crate::tool::RUN_TOOL_NAME; use crate::tool::WEB_NAMESPACE; + use codex_api::ExternalWebAccess; + use codex_api::ExternalWebAccessMode; + use codex_protocol::config_types::WebSearchMode; + + #[test] + fn external_web_access_preserves_legacy_values_until_indexed() { + assert_eq!( + [ + WebSearchMode::Disabled, + WebSearchMode::Cached, + WebSearchMode::Indexed, + WebSearchMode::Live, + ] + .map(external_web_access_for_mode), + [ + ExternalWebAccess::Boolean(false), + ExternalWebAccess::Boolean(false), + ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed), + ExternalWebAccess::Boolean(true), + ] + ); + } #[test] fn installed_extension_contributes_web_run_when_enabled() { diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index 6417a705fe3c..10fb0dd65e9b 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -1,9 +1,9 @@ use codex_api::SearchInput; use codex_core::parse_turn_item; use codex_protocol::items::TurnItem; -use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::models::plaintext_agent_message_content; use codex_tools::retain_tail_from_last_n_user_messages; use codex_tools::truncate_assistant_output_text_to_token_budget; @@ -29,23 +29,17 @@ pub(crate) fn recent_input(items: &[ResponseItem]) -> Option { fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { match item { ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => { - messages.push(item.clone()); + let mut message = item.clone(); + message.set_id(/*new_id*/ None); + messages.push(message); } ResponseItem::AgentMessage { author, content, - metadata, + internal_chat_message_metadata_passthrough: metadata, .. } => { - let text = content - .iter() - .filter_map(|content| match content { - AgentMessageInputContent::InputText { text } => Some(text.as_str()), - AgentMessageInputContent::EncryptedContent { .. } => None, - }) - .collect::>() - .join("\n"); - if !text.trim().is_empty() { + if let Some(text) = plaintext_agent_message_content(content) { messages.push(ResponseItem::Message { id: None, role: ASSISTANT_ROLE.to_string(), @@ -53,16 +47,16 @@ 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(), }); } } ResponseItem::Message { - id, + id: _, role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } if role == USER_ROLE && matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) => { @@ -73,11 +67,11 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { .collect::>(); if !content.is_empty() { messages.push(ResponseItem::Message { - id: id.clone(), + id: None, role: role.clone(), content, phase: phase.clone(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }); } } @@ -110,26 +104,30 @@ mod tests { } }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } #[test] fn keeps_current_user_and_previous_visible_turn() { + let mut previous_user = message(USER_ROLE, "previous user"); + previous_user.set_id(Some("msg_previous_user".to_string())); + let mut previous_assistant = message(ASSISTANT_ROLE, "previous assistant"); + previous_assistant.set_id(Some("msg_previous_assistant".to_string())); let items = vec![ message("system", "system"), message(USER_ROLE, "old user"), message(ASSISTANT_ROLE, "old assistant"), - message(USER_ROLE, "previous user"), + previous_user, ResponseItem::FunctionCall { id: None, name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, - message(ASSISTANT_ROLE, "previous assistant"), + previous_assistant, message("developer", "developer"), message(USER_ROLE, "current user"), message(ASSISTANT_ROLE, "current commentary"), @@ -160,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 6ee0b38a24dd..7e5f626d8d01 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 { @@ -155,13 +154,13 @@ pub fn missing_subagent_names( Ok(names) } -pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result { +pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result> { if !source_agents.is_dir() { - return Ok(0); + return Ok(Vec::new()); } fs::create_dir_all(target_agents)?; - let mut imported = 0usize; + let mut imported = Vec::new(); for source_file in agent_source_files(source_agents)? { let Some(target) = subagent_target_file(&source_file, target_agents) else { continue; @@ -174,7 +173,7 @@ pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Resul continue; }; fs::write(&target, render_agent_toml(&document.body, &metadata)?)?; - imported += 1; + imported.push(metadata.name); } Ok(imported) @@ -195,13 +194,13 @@ pub fn missing_command_names( .collect()) } -pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result { +pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result> { if !source_commands.is_dir() { - return Ok(0); + return Ok(Vec::new()); } fs::create_dir_all(target_skills)?; - let mut imported = 0usize; + let mut imported = Vec::new(); for (source_file, name) in unique_supported_command_sources(source_commands)? { let document = parse_document(&source_file)?; let target_dir = target_skills.join(&name); @@ -217,7 +216,7 @@ pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Resu target_dir.join("SKILL.md"), render_command_skill(&document.body, &name, &description, &source_name), )?; - imported += 1; + imported.push(name); } Ok(imported) @@ -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 2990c0f5ea49..6391d27bd378 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 f59b89e9a87e..35b669b0a0fc 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -12,6 +12,11 @@ pub struct CodeModeConfigToml { /// Exact tool namespaces to omit from the code-mode nested tool surface. #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tool_namespaces: Option>, + /// Exact tool namespaces to expose only as direct model tools. + /// These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted + /// from the nested code-mode tool surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub direct_only_tool_namespaces: Option>, } impl FeatureConfig for CodeModeConfigToml { @@ -41,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")] @@ -68,6 +74,91 @@ 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 { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub limit_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// 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, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] + pub prefill_token_weight: Option, +} + +impl FeatureConfig for RolloutBudgetConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CurrentTimeSource { + #[default] + System, + External, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentTimeReminderConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub reminder_interval_model_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub clock_source: Option, +} + +impl FeatureConfig for CurrentTimeReminderConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RemovedAppsMcpPathOverrideConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 80378ea8a1b1..b6112b62ca29 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -17,12 +17,16 @@ use toml::Table; mod feature_configs; mod legacy; pub use feature_configs::CodeModeConfigToml; +pub use feature_configs::CurrentTimeReminderConfigToml; +pub use feature_configs::CurrentTimeSource; pub use feature_configs::MultiAgentV2ConfigToml; pub use feature_configs::NetworkProxyConfigToml; pub use feature_configs::NetworkProxyDomainPermissionToml; 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; @@ -123,6 +127,8 @@ pub enum Feature { UseLegacyLandlock, /// Experimental shell snapshotting. ShellSnapshot, + /// Allow turns to start while selected executors are still starting. + DeferredExecutor, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, /// Enable startup memory extraction and file-backed memory consolidation. @@ -131,16 +137,18 @@ pub enum Feature { LocalThreadStoreCompression, /// Enable the Chronicle sidecar for passive screen-context memories. Chronicle, - /// Append additional AGENTS.md guidance to user instructions. - ChildAgentsMd, /// Compress request bodies (zstd) when sending streaming requests to codex-backend. EnableRequestCompression, /// Start the managed network proxy for sandboxed sessions. NetworkProxy, + /// Respect host system proxy settings for Codex-owned network clients. + RespectSystemProxy, /// Enable collab tools. Collab, /// Enable task-path-based multi-agent routing. MultiAgentV2, + /// Removed compatibility flag retained as a no-op. + MultiAgentMode, /// Enable CSV-backed agent job tools. SpawnCsv, /// Enable apps. @@ -187,8 +195,10 @@ pub enum Feature { ToolRouter, /// 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, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Removed compatibility flag for deleted skill env var dependency prompting. @@ -203,6 +213,10 @@ pub enum Feature { Goals, /// Add current context-window metadata to model-visible context. TokenBudget, + /// Track and report a shared token budget across a session's agent threads. + RolloutBudget, + /// Add current-time reminders to model-visible context. + CurrentTimeReminder, /// Expose an input-interruptible sleep tool. SleepTool, /// Route MCP tool approval prompts through the MCP elicitation request path. @@ -219,8 +233,12 @@ 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. + UseAgentIdentity, /// Enable workspace dependency support. WorkspaceDependencies, /// Enable JavaScript workflows that can share the TUI app-server. @@ -458,7 +476,7 @@ impl Features { "tool_search" | "apps_mcp_path_override" => { continue; } - "image_detail_original" => { + "image_detail_original" | "resize_all_images" => { continue; } "plugin_hooks" => { @@ -588,7 +606,7 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option } fn web_search_details() -> &'static str { - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." + "Set `web_search` to `\"live\"`, `\"indexed\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." } /// Keys accepted in `[features]` tables. @@ -620,6 +638,12 @@ pub struct FeaturesToml { pub code_mode: Option>, #[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>, #[serde(default, rename = "apps_mcp_path_override", skip_serializing)] #[schemars(skip)] removed_apps_mcp_path_override: Option>, @@ -652,6 +676,19 @@ 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); + } + if let Some(enabled) = self + .current_time_reminder + .as_ref() + .and_then(FeatureToml::enabled) + { + entries.insert(Feature::CurrentTimeReminder.key().to_string(), enabled); + } if let Some(enabled) = self.network_proxy.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::NetworkProxy.key().to_string(), enabled); } @@ -663,6 +700,9 @@ impl FeaturesToml { let Self { code_mode, multi_agent_v2, + token_budget, + rollout_budget, + current_time_reminder, removed_apps_mcp_path_override: _, network_proxy, entries, @@ -677,6 +717,12 @@ 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 { + materialize_resolved_feature_enabled(current_time_reminder, enabled); } else if spec.id == Feature::NetworkProxy { materialize_resolved_feature_enabled(network_proxy, enabled); } else { @@ -796,6 +842,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::DeferredExecutor, + key: "deferred_executor", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::JsRepl, key: "js_repl", @@ -890,12 +942,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, - FeatureSpec { - id: Feature::ChildAgentsMd, - key: "child_agents_md", - stage: Stage::UnderDevelopment, - default_enabled: false, - }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", @@ -978,6 +1024,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::RespectSystemProxy, + key: "respect_system_proxy", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Collab, key: "multi_agent", @@ -990,6 +1042,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::MultiAgentMode, + key: "multi_agent_mode", + stage: Stage::Removed, + default_enabled: false, + }, FeatureSpec { id: Feature::SpawnCsv, key: "enable_fanout", @@ -1113,6 +1171,12 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ResizeAllImages, key: "resize_all_images", + stage: Stage::Removed, + default_enabled: true, + }, + FeatureSpec { + id: Feature::ItemIds, + key: "item_ids", stage: Stage::UnderDevelopment, default_enabled: false, }, @@ -1170,6 +1234,18 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RolloutBudget, + key: "rollout_budget", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, + FeatureSpec { + id: Feature::CurrentTimeReminder, + key: "current_time_reminder", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SleepTool, key: "sleep_tool", @@ -1272,12 +1348,24 @@ 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", stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::UseAgentIdentity, + key: "use_agent_identity", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::WorkspaceDependencies, key: "workspace_dependencies", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 73dbf99cbee0..1ec8aa3b9648 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -481,6 +481,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)])); @@ -609,51 +626,13 @@ 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(); features.enable(Feature::CodeMode); features.enable(Feature::MultiAgentV2); features.enable(Feature::NetworkProxy); + features.enable(Feature::RespectSystemProxy); let mut features_toml = FeaturesToml { multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { @@ -712,12 +691,15 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( #[test] fn unstable_warning_event_only_mentions_enabled_under_development_features() { let mut configured_features = Table::new(); - configured_features.insert("child_agents_md".to_string(), TomlValue::Boolean(true)); + configured_features.insert( + "apply_patch_streaming_events".to_string(), + TomlValue::Boolean(true), + ); configured_features.insert("personality".to_string(), TomlValue::Boolean(true)); configured_features.insert("unknown".to_string(), TomlValue::Boolean(true)); let mut features = Features::with_defaults(); - features.enable(Feature::ChildAgentsMd); + features.enable(Feature::ApplyPatchStreamingEvents); let warning = unstable_features_warning_event( Some(&configured_features), @@ -730,7 +712,7 @@ fn unstable_warning_event_only_mentions_enabled_under_development_features() { let EventMsg::Warning(WarningEvent { message }) = warning.msg else { panic!("expected warning event"); }; - assert!(message.contains("child_agents_md")); + assert!(message.contains("apply_patch_streaming_events")); assert!(!message.contains("personality")); assert!(message.contains("/tmp/config.toml")); } diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index c49e57aeb976..5f7891932386 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -820,22 +820,30 @@ mod tests { #[test] fn session_streams_updates_before_walk_complete() { - let dir = create_temp_tree(/*file_count*/ 600); + let dir = create_temp_tree(/*file_count*/ 10_000); let reporter = Arc::new(RecordingReporter::default()); let session = create_session( vec![dir.path().to_path_buf()], - FileSearchOptions::default(), + FileSearchOptions { + threads: NonZero::new(/*n*/ 1).unwrap(), + ..Default::default() + }, reporter.clone(), /*cancel_flag*/ None, ) .expect("session"); session.update_query("file-0"); + let streamed_update = reporter.wait_until( + &reporter.updates, + &reporter.update_cv, + Duration::from_secs(5), + |updates| updates.iter().any(|snapshot| !snapshot.walk_complete), + ); let completed = reporter.wait_for_complete(Duration::from_secs(5)); + assert!(streamed_update); assert!(completed); - let updates = reporter.updates(); - assert!(updates.iter().any(|snapshot| !snapshot.walk_complete)); } #[test] diff --git a/codex-rs/file-system/Cargo.toml b/codex-rs/file-system/Cargo.toml index 087e5da6c0a1..382eacb43d33 100644 --- a/codex-rs/file-system/Cargo.toml +++ b/codex-rs/file-system/Cargo.toml @@ -8,9 +8,11 @@ license.workspace = true workspace = true [dependencies] +bytes = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } +futures = { workspace = true } serde = { workspace = true, features = ["derive"] } [lib] diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 08c89f8f40b5..4b37ef517624 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -1,3 +1,4 @@ +use bytes::Bytes; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -10,10 +11,16 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; +use futures::Stream; use std::future::Future; use std::io; use std::path::Path; use std::pin::Pin; +use std::task::Context; +use std::task::Poll; + +/// Maximum chunk size returned by [`ExecutorFileSystem::read_file_stream`]. +pub const FILE_READ_CHUNK_SIZE: usize = 1024 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CreateDirectoryOptions { @@ -56,6 +63,8 @@ pub struct FileSystemSandboxContext { pub permissions: PermissionProfile, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub windows_sandbox_level: WindowsSandboxLevel, #[serde(default)] pub windows_sandbox_private_desktop: bool, @@ -103,6 +112,7 @@ impl FileSystemSandboxContext { Self { permissions: permissions.into(), cwd, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, @@ -145,6 +155,7 @@ impl FileSystemSandboxContext { pub fn drop_cwd_if_unused(mut self) -> Self { if !self.has_cwd_dependent_permissions() { self.cwd = None; + self.workspace_roots.clear(); } self } @@ -156,6 +167,28 @@ pub type FileSystemResult = io::Result; pub type ExecutorFileSystemFuture<'a, T> = Pin> + Send + 'a>>; +/// Stream of immutable chunks read from an [`ExecutorFileSystem`]. +pub struct FileSystemReadStream { + inner: Pin> + Send + 'static>>, +} + +impl FileSystemReadStream { + /// Wraps a filesystem byte stream. + pub fn new(stream: impl Stream> + Send + 'static) -> Self { + Self { + inner: Box::pin(stream), + } + } +} + +impl Stream for FileSystemReadStream { + type Item = FileSystemResult; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.inner.as_mut().poll_next(cx) + } +} + /// Abstract filesystem access used by components that may operate locally or via /// a remote environment. pub trait ExecutorFileSystem: Send + Sync { @@ -172,6 +205,13 @@ pub trait ExecutorFileSystem: Send + Sync { sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, Vec>; + /// Reads a file as a stream of chunks no larger than [`FILE_READ_CHUNK_SIZE`]. + fn read_file_stream<'a>( + &'a self, + path: &'a PathUri, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSystemReadStream>; + /// Reads a file and decodes it as UTF-8 text. fn read_file_text<'a>( &'a self, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 3e6d821110a5..5843e03115c7 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -175,6 +175,7 @@ async fn run_cmd_result_with_permission_profile_for_cwd( capture_policy: ExecCapturePolicy::ShellTool, env: create_env_from_core_vars(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -432,6 +433,7 @@ async fn assert_network_blocked(cmd: &[&str]) { capture_policy: ExecCapturePolicy::ShellTool, env: create_env_from_core_vars(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/login/src/auth/account_pool.rs b/codex-rs/login/src/auth/account_pool.rs index b353f60e8b9e..6e9283c7a4a8 100644 --- a/codex-rs/login/src/auth/account_pool.rs +++ b/codex-rs/login/src/auth/account_pool.rs @@ -14,6 +14,7 @@ use codex_protocol::account::PlanType; use serde_json::Value; use crate::CodexAuth; +use crate::outbound_proxy::AuthRouteConfig; use super::account_pool_selection::AccountPoolAssignmentKey; use super::account_pool_selection::AccountPoolAuthSelection; @@ -125,6 +126,7 @@ impl AccountPoolManager { config: AccountPoolToml, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option, + auth_route_config: Option, ) -> Option { if !config.enabled { return None; @@ -145,8 +147,10 @@ impl AccountPoolManager { codex_home.join("accounts").join(account_id), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, + /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url.clone(), AuthKeyringBackendKind::default(), + auth_route_config.clone(), ) .await, ), @@ -1708,6 +1712,7 @@ mod tests { }, AuthCredentialsStoreMode::File, chatgpt_base_url, + /*auth_route_config*/ None, ) .await .expect("account pool should be enabled") diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 435371a53f10..79ee2e7f54b7 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -1,43 +1,151 @@ +use std::future::Future; +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; +use codex_agent_identity::generate_agent_key_material; +use codex_agent_identity::is_retryable_registration_error; +use codex_agent_identity::public_key_ssh_from_private_key_pkcs8_base64; +use codex_agent_identity::register_agent_identity; use codex_agent_identity::register_agent_task; use codex_protocol::account::PlanType as AccountPlanType; -use std::env; +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; -const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; -const CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"; +pub(super) const MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS: usize = 3; + +pub(super) fn agent_identity_authapi_base_url( + chatgpt_base_url: Option<&str>, +) -> std::io::Result { + let environment = match chatgpt_base_url { + Some(chatgpt_base_url) => ChatGptEnvironment::from_chatgpt_base_url(chatgpt_base_url) + .map_err(std::io::Error::other)?, + None => ChatGptEnvironment::default(), + }; + Ok(environment.agent_identity_authapi_base_url().to_string()) +} + +pub(super) fn require_agent_identity_authapi_base_url( + agent_identity_authapi_base_url: Option<&str>, +) -> std::io::Result<&str> { + agent_identity_authapi_base_url.ok_or_else(|| { + std::io::Error::other( + "Agent Identity only supports production and staging ChatGPT environments", + ) + }) +} + +#[derive(Debug, Error)] +pub enum AgentIdentityAuthError { + #[error( + "agent identity bootstrap unavailable after {attempts} attempts during {operation}: {message}" + )] + BootstrapUnavailable { + operation: &'static str, + attempts: usize, + message: String, + }, +} + +impl AgentIdentityAuthError { + pub fn is_bootstrap_unavailable(error: &std::io::Error) -> bool { + matches!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(Self::BootstrapUnavailable { .. }) + ) + } +} + +#[derive(Debug, Error)] +#[error("retryable agent identity registration failure: {message}")] +pub(super) struct RetryableAgentIdentityRegistrationError { + message: String, +} + +impl RetryableAgentIdentityRegistrationError { + pub(super) fn new(message: String) -> Self { + Self { message } + } +} #[derive(Clone, Debug)] pub struct AgentIdentityAuth { - record: AgentIdentityAuthRecord, - process_task_id: String, + record: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ManagedChatGptAgentIdentityBinding { + pub(super) account_id: String, + pub(super) chatgpt_user_id: String, + pub(super) email: Option, + pub(super) plan_type: AccountPlanType, + pub(super) chatgpt_account_is_fedramp: bool, + pub(super) access_token: String, } impl AgentIdentityAuth { - pub async fn load(record: AgentIdentityAuthRecord) -> std::io::Result { - let agent_identity_authapi_base_url = agent_identity_authapi_base_url(); - let process_task_id = register_agent_task( - &build_reqwest_client(), - &agent_identity_authapi_base_url, - key(&record), - ) - .await - .map_err(std::io::Error::other)?; + 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, + auth_route_config, + ) + .await?, + ); + } Ok(Self { - record, - process_task_id, + record: Arc::new(record), }) } + pub async fn from_jwt( + 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, auth_route_config).await?; + Self::from_record(record, agent_identity_authapi_base_url, auth_route_config).await + } + + #[cfg(test)] + fn from_initialized_record(mut record: AgentIdentityAuthRecord, run_task_id: String) -> Self { + record.task_id = Some(run_task_id); + Self { + record: Arc::new(record), + } + } + pub fn record(&self) -> &AgentIdentityAuthRecord { - &self.record + self.record.as_ref() } - pub fn process_task_id(&self) -> &str { - &self.process_task_id + pub fn run_task_id(&self) -> &str { + match self.record.task_id.as_deref() { + Some(task_id) => task_id, + None => unreachable!("AgentIdentityAuth should only be constructed with a task_id"), + } } pub fn account_id(&self) -> &str { @@ -48,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 { @@ -61,15 +169,171 @@ impl AgentIdentityAuth { } } -fn agent_identity_authapi_base_url() -> String { - env::var(CODEX_AGENT_IDENTITY_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_AGENT_IDENTITY_AUTHAPI_BASE_URL.to_string()) +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 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, + agent_identity_authapi_base_url, + &binding.access_token, + binding.chatgpt_account_is_fedramp, + &key_material, + build_abom(session_source.clone()), + vec!["responsesapi".to_string()], + ) + .await + .map_err(|err| { + if is_retryable_registration_error(&err) { + std::io::Error::other(RetryableAgentIdentityRegistrationError::new( + err.to_string(), + )) + } else { + std::io::Error::other(err) + } + }) + }) + .await + .map_err(|err| classify_bootstrap_error("agent identity registration", err))?; + + let record = AgentIdentityAuthRecord { + agent_runtime_id: runtime_id, + agent_private_key: key_material.private_key_pkcs8_base64, + account_id: binding.account_id, + chatgpt_user_id: binding.chatgpt_user_id, + email: binding.email, + plan_type: binding.plan_type, + chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp, + task_id: None, + }; + AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config) + .await + .map_err(|err| classify_bootstrap_error("agent task registration", err)) +} + +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_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)?; + Ok(claims.into()) +} + +pub(super) fn record_needs_task_registration(record: &AgentIdentityAuthRecord) -> bool { + record + .task_id + .as_deref() + .is_none_or(|task_id| task_id.trim().is_empty()) +} + +pub(super) fn record_matches_managed_chatgpt_binding( + record: &AgentIdentityAuthRecord, + binding: &ManagedChatGptAgentIdentityBinding, +) -> bool { + record.account_id == binding.account_id + && record.chatgpt_user_id == binding.chatgpt_user_id + && public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key).is_ok() +} + +pub(super) fn classify_bootstrap_error( + operation: &'static str, + err: std::io::Error, +) -> std::io::Error { + if is_retryable_io_registration_error(&err) { + std::io::Error::other(AgentIdentityAuthError::BootstrapUnavailable { + operation, + attempts: MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS, + message: err.to_string(), + }) + } else { + err + } +} + +pub(super) fn is_retryable_io_registration_error(err: &std::io::Error) -> bool { + err.get_ref().is_some_and( + ::is::< + RetryableAgentIdentityRegistrationError, + >, + ) +} + +pub(super) async fn retry_registration(mut operation: F) -> std::io::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut attempt = 1; + loop { + match operation().await { + Ok(value) => return Ok(value), + Err(err) + if attempt < MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS + && is_retryable_io_registration_error(&err) => + { + tracing::warn!( + attempt, + max_attempts = MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS, + error = %err, + "agent identity registration attempt failed; retrying" + ); + attempt += 1; + } + Err(err) => return Err(err), + } + } +} + +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(&client, record, agent_identity_authapi_base_url).await + }) + .await } -fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { +async fn register_task_for_record( + client: &reqwest::Client, + record: &AgentIdentityAuthRecord, + agent_identity_authapi_base_url: &str, +) -> std::io::Result { + register_agent_task( + client, + agent_identity_authapi_base_url, + key_for_record(record), + ) + .await + .map_err(|err| { + if is_retryable_registration_error(&err) { + std::io::Error::other(RetryableAgentIdentityRegistrationError::new( + err.to_string(), + )) + } else { + std::io::Error::other(err) + } + }) +} + +fn key_for_record(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { AgentIdentityKey { agent_runtime_id: &record.agent_runtime_id, private_key_pkcs8_base64: &record.agent_private_key, @@ -78,63 +342,214 @@ fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + use codex_agent_identity::generate_agent_key_material; + 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 super::*; - use serial_test::serial; - #[test] - #[serial(auth_env)] - fn agent_identity_authapi_base_url_prefers_env_value() { - let _guard = EnvVarGuard::set( - CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR, - "https://authapi.example.test/api/accounts/", - ); - assert_eq!( - agent_identity_authapi_base_url(), - "https://authapi.example.test/api/accounts" - ); + fn agent_identity_record(private_key: String) -> AgentIdentityAuthRecord { + AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-1".to_string(), + agent_private_key: private_key, + account_id: "account-1".to_string(), + chatgpt_user_id: "user-1".to_string(), + email: Some("agent@example.com".to_string()), + plan_type: AccountPlanType::Plus, + chatgpt_account_is_fedramp: false, + task_id: None, + } } - #[test] - #[serial(auth_env)] - fn agent_identity_authapi_base_url_uses_prod_authapi_by_default() { - let _guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR); - assert_eq!( - agent_identity_authapi_base_url(), - PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL - ); + fn agent_identity_record_with_generated_key() -> AgentIdentityAuthRecord { + let key_material = generate_agent_key_material().expect("generate key material"); + agent_identity_record(key_material.private_key_pkcs8_base64) + } + + #[tokio::test] + async fn from_record_registers_task() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + }))) + .expect(1) + .mount(&server) + .await; + + let auth = AgentIdentityAuth::from_record( + agent_identity_record_with_generated_key(), + &server.uri(), + /*auth_route_config*/ None, + ) + .await?; + + assert_eq!(auth.run_task_id(), "task-run-1"); + let requests = server + .received_requests() + .await + .expect("failed to fetch task registration request"); + let request_body = requests[0] + .body_json::() + .expect("task registration request should be JSON"); + let request_body = request_body + .as_object() + .expect("request body should be object"); + assert!(request_body.get("timestamp").is_some()); + assert!(request_body.get("signature").is_some()); + assert_eq!(request_body.len(), 2); + Ok(()) } - struct EnvVarGuard { - key: &'static str, - original: Option, + #[tokio::test] + async fn from_jwt_registers_task() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + }))) + .expect(1) + .mount(&server) + .await; + + let record = agent_identity_record_with_generated_key(); + let jwt = signed_agent_identity_jwt(&record)?; + let auth = AgentIdentityAuth::from_jwt( + &jwt, + &format!("{}/backend-api", server.uri()), + &server.uri(), + /*auth_route_config*/ None, + ) + .await?; + + assert_eq!(auth.record().agent_runtime_id, "agent-runtime-1"); + assert_eq!(auth.run_task_id(), "task-run-1"); + Ok(()) } - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let original = env::var_os(key); - unsafe { - env::set_var(key, value); - } - Self { key, original } - } + #[test] + fn run_task_is_shared_across_clones() { + let auth = AgentIdentityAuth::from_initialized_record( + agent_identity_record_with_generated_key(), + "task-run-1".to_string(), + ); + let cloned = auth.clone(); - fn remove(key: &'static str) -> Self { - let original = env::var_os(key); - unsafe { - env::remove_var(key); - } - Self { key, original } - } + assert!(Arc::ptr_eq(&auth.record, &cloned.record)); + assert_eq!(cloned.run_task_id(), "task-run-1"); } - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => env::set_var(self.key, value), - None => env::remove_var(self.key), + #[tokio::test] + async fn from_record_retries_transient_registration() -> anyhow::Result<()> { + let server = MockServer::start().await; + let request_count = Arc::new(AtomicUsize::new(0)); + let response_count = Arc::clone(&request_count); + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(move |_request: &wiremock::Request| { + if response_count.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(500) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + })) } - } - } + }) + .expect(2) + .mount(&server) + .await; + let auth = AgentIdentityAuth::from_record( + agent_identity_record_with_generated_key(), + &server.uri(), + /*auth_route_config*/ None, + ) + .await?; + + assert_eq!(request_count.load(Ordering::SeqCst), 2); + assert_eq!(auth.run_task_id(), "task-run-1"); + Ok(()) } + + fn signed_agent_identity_jwt( + record: &AgentIdentityAuthRecord, + ) -> jsonwebtoken::errors::Result { + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); + header.kid = Some("test-key".to_string()); + jsonwebtoken::encode( + &header, + &json!({ + "iss": "https://chatgpt.com/codex-backend/agent-identity", + "aud": "codex-app-server", + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": record.agent_runtime_id, + "agent_private_key": record.agent_private_key, + "account_id": record.account_id, + "chatgpt_user_id": record.chatgpt_user_id, + "email": record.email, + "plan_type": record.plan_type, + "chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp, + }), + &jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?, + ) + } + + fn test_jwks_body() -> serde_json::Value { + json!({ + "keys": [{ + "kty": "RSA", + "kid": "test-key", + "use": "sig", + "alg": "RS256", + "n": "1qQF2MqTrGAMDm7wXbjJP5sWqGA83tAGUs2ksy7iJXLJdhCg4AtwGm4SFl4f6kxhCSzlN1QdXuZjvRT2wZZiGUi9xUE28rf4WLrTxSnwqLuTy5knMP08yC0t_0YU_FGPZMcWb14hG05IvZr8UbmRaVagxSR8H4rSIymRoVwwmFSrqz068XrWGSYNIfLEASyo5GdAaqmk1JALINHgYGQJVxMxtwcvDxoVKmC7eltUNymMNBZhsv4E8sx9YNLpBoEibznfEpDU_DGzrM5eZCsQzaqbhBOlGd427ifud_Nnd9cPqzgCUc23-0FXSPfpbgksCXAwAmD0OFjQWrgqVdKL6Q", + "e": "AQAB", + }] + }) + } + + const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO +bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 +FPbBlmIZSL3FQTbyt/hYutPFKfCou5PLmScw/TzILS3/RhT8UY9kxxZvXiEbTki9 +mvxRuZFpVqDFJHwfitIjKZGhXDCYVKurPTrxetYZJg0h8sQBLKjkZ0BqqaTUkAsg +0eBgZAlXEzG3By8PGhUqYLt6W1Q3KYw0FmGy/gTyzH1g0ukGgSJvOd8SkNT8MbOs +zl5kKxDNqpuEE6UZ3jbuJ+5382d31w+rOAJRzbf7QVdI9+luCSwJcDACYPQ4WNBa +uCpV0ovpAgMBAAECggEAVu84LwZdqYN9XpswX8VoPYrjMm9IODapWQBRpQFoNyK2 +1ksF3bjEPvA2Azk8U/l7k+vLKw22l6lY3EyRZPcz5GnB8xLm3ogE3mtNOp4yCyVu +RxhQ91aaN7mU17/a4BdorLi2LYVCg3zBmYociD1Q2AluNGsCmwPu+K7tfR2J0Sg8 +NjqiTbDG1XDpR/icwgC9t6vh8lZpCHDhF4tbQfLLVLeA/OdcuzXDyMCXbmdVIdBQ +rm4aIFmr2e1/2ctTbCg85S6AGFTH+pSLjrwTzyvf+F6NW5uNjLQAQLFj+EznBDxj +Xdx90cySrjsKK6PVWQF4RiTvkSW8eWL7R6B2FZbGwQKBgQDuVQRj72hWloR7mbEL +aUEEv3pIXTMXWEsoMBNczos/1L1RnAN1AI44TurznasPZAWvQj+kVbLDR+TAeZrL +iA8HIWswQUI18hFmgKzSkwIXGtubcKVrgsKeS4lMDKCM/Ef6WAYdeq6ronoY5lCN +YrJFmGp81W5zcV7lyiycgbSiGwKBgQDmjWYf6pZjrK7Z+OJ3X1AZfi2vss15SCvL +3fPgzIDbViztpGyQhc3DQZIsBNIu0xZp/veGce9TEeTds2ro9NfdJFeou8+fC7Pq +sOsM3amGFFi+ZW/9BWyjZEM88bgWWAjqLHbpfHDxjAf5CSxddqxgHlbP0Ytyb1Vg +gmPDn9YKSwKBgQDbTi3hC35WFuDHn0/zcSHcDZmnFuOZeqyFyV83yfMGhGrEuqvP +sPgtRikajJ3IZsB4WZyYSidZXEFY/0z6NjOl2xF38MTNQPbT/FmK1q1Yt2UWrlv5 +BvSwlk87RG9D7C0LZo4R+D7cPoDdgqjiwMvMEIkEX5zn641oI1ZTmWKuuwKBgQCD +KF+3unnRvHRAVoFnTZbA2fJdqMeRvogD04GhGlYX8V9f1hFY6nXTJaNlXVzA/J8c +r8ra9kgjJuPfZ+ljG58OFFW2DRohLcQtuHYPfK6rMzoFHqnl9EcIcMp7ijuionR3 +29HOJFgQYgxLFXfit9d6WugiE+BTupiEbckZif13HwKBgE/lAlkVHP6YahOO2Ljc +J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN +5da0D4h2rYOXnbYIg0BVu4spQbaM6ewsp66b8+MzLOBvj8SzWdt1Oyw0q/MRyQAR +8U4M2TSWCKUY/A6sT4W8+mT9 +-----END PRIVATE KEY-----"#; } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 9053f904ecda..08d65abfaa38 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::AuthMode; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; +use codex_protocol::protocol::SessionSource; use base64::Engine; use codex_protocol::config_types::ForcedLoginMethod; @@ -14,11 +15,14 @@ use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::json; use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use tempfile::TempDir; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::body_partial_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -95,7 +99,7 @@ fn login_with_api_key_overwrites_existing_auth_json() { } #[tokio::test] -async fn login_with_access_token_writes_only_token() { +async fn login_with_access_token_writes_agent_identity_jwt() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); let record = agent_identity_record(WORKSPACE_ID_ALLOWED); @@ -108,7 +112,8 @@ async fn login_with_access_token_writes_only_token() { .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); super::login_with_access_token( dir.path(), @@ -117,6 +122,7 @@ async fn login_with_access_token_writes_only_token() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("login_with_access_token should succeed"); @@ -127,14 +133,76 @@ async fn login_with_access_token_writes_only_token() { .expect("auth.json should parse"); assert_eq!(auth.auth_mode, Some(AuthMode::AgentIdentity)); assert_eq!( - auth.agent_identity.as_deref(), - Some(agent_identity.as_str()) + auth.agent_identity, + Some(AgentIdentityStorage::Jwt(agent_identity)) ); assert!(auth.tokens.is_none(), "tokens should be cleared"); assert!(auth.openai_api_key.is_none(), "API key should be cleared"); server.verify().await; } +#[tokio::test] +#[serial(auth_env)] +async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result<()> { + let _access_token_guard = remove_access_token_env_var(); + let codex_home = tempdir()?; + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + mock_agent_task_registration(&server, "", &record.agent_runtime_id, "task-id").await; + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity.clone())), + personal_access_token: None, + bedrock_api_key: None, + }, + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::Direct, + )?; + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + Some(&chatgpt_base_url), + AuthKeyringBackendKind::Direct, + Some(&authapi_base_url), + /*auth_route_config*/ None, + ) + .await? + .expect("auth should load"); + + let CodexAuth::AgentIdentity(agent_identity_auth) = auth else { + panic!("stored JWT should load as agent identity auth"); + }; + assert_eq!(agent_identity_auth.run_task_id(), "task-id"); + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&get_auth_file(codex_home.path())) + .expect("auth.json should parse"); + assert_eq!( + auth.agent_identity, + Some(AgentIdentityStorage::Jwt(agent_identity)) + ); + server.verify().await; + Ok(()) +} + #[tokio::test] #[serial(auth_env)] async fn login_with_access_token_writes_only_personal_access_token() { @@ -160,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"); @@ -212,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"); @@ -244,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"); @@ -267,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"); @@ -278,6 +350,402 @@ async fn login_with_access_token_rejects_invalid_jwt() { ); } +#[tokio::test] +#[serial(auth_env)] +async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, + ) + .await? + .expect("auth should load"); + + assert!( + auth.agent_identity_auth( + AgentIdentityAuthPolicy::JwtOnly, + /*agent_identity_authapi_base_url*/ None, + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await? + .is_none() + ); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .and(header("authorization", "Bearer test-access-token")) + .and(body_partial_json(json!({ + "abom": { + "agent_harness_id": "codex-cli", + }, + "capabilities": ["responsesapi"], + "ttl": null, + }))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "agent_runtime_id": "agent-runtime-123", + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await; + + let agent_auth = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should register"); + let reused = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should be reused"); + + assert_eq!( + agent_auth.record().agent_runtime_id, + reused.record().agent_runtime_id + ); + assert_eq!(agent_auth.run_task_id(), "task-123"); + assert_eq!(reused.run_task_id(), "task-123"); + assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123"); + assert_eq!(agent_auth.record().account_id, "account-123"); + assert_eq!(agent_auth.record().chatgpt_user_id, "user-12345"); + assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123")); + assert_eq!(reused.record().task_id.as_deref(), Some("task-123")); + let persisted = auth + .stored_managed_chatgpt_agent_identity_record("account-123") + .expect("identity should persist"); + assert_eq!(persisted.agent_runtime_id, "agent-runtime-123"); + assert_eq!(persisted.task_id.as_deref(), Some("task-123")); + + let reloaded = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, + ) + .await? + .expect("auth should reload"); + let reloaded_agent_auth = reloaded + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should reload from storage"); + assert_eq!( + reloaded_agent_auth.record().agent_runtime_id, + "agent-runtime-123" + ); + assert_eq!(reloaded_agent_auth.run_task_id(), "task-123"); + Ok(()) +} + +#[tokio::test] +#[serial(auth_env)] +async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + let registration_count = Arc::new(AtomicUsize::new(0)); + let response_count = Arc::clone(®istration_count); + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .respond_with(move |_request: &wiremock::Request| { + if response_count.fetch_add(1, Ordering::SeqCst) < 2 { + ResponseTemplate::new(/*status*/ 503) + } else { + ResponseTemplate::new(/*status*/ 200).set_body_json(json!({ + "agent_runtime_id": "agent-runtime-123", + })) + } + }) + .expect(/*requests*/ 3) + .mount(&server) + .await; + mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await; + + let agent_auth = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should register after retries"); + + assert_eq!(registration_count.load(Ordering::SeqCst), 3); + assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123"); + assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123")); + assert_eq!( + auth.stored_managed_chatgpt_agent_identity_record("account-123") + .and_then(|record| record.task_id), + Some("task-123".to_string()) + ); + Ok(()) +} + +#[tokio::test] +#[serial(auth_env)] +async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .respond_with(ResponseTemplate::new(/*status*/ 503)) + .expect(/*requests*/ 3) + .mount(&server) + .await; + + let err = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await + .expect_err("retry exhaustion should return an error"); + + assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + assert!( + auth.stored_managed_chatgpt_agent_identity_record("account-123") + .is_none() + ); + Ok(()) +} + +#[tokio::test] +#[serial(auth_env)] +async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> +{ + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let mut record = agent_identity_record("account-123"); + record.chatgpt_user_id = "user-12345".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)?; + auth_json.agent_identity = Some(AgentIdentityStorage::Record(record.clone())); + storage.save(&auth_json)?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!( + "/v1/agent/{}/task/register", + record.agent_runtime_id + ))) + .respond_with(ResponseTemplate::new(/*status*/ 503)) + .expect(/*requests*/ 3) + .mount(&server) + .await; + + let err = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await + .expect_err("task retry exhaustion should return an error"); + + assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + record.task_id = None; + assert_eq!( + auth.stored_managed_chatgpt_agent_identity_record("account-123"), + Some(record) + ); + Ok(()) +} + +#[tokio::test] +#[serial(auth_env)] +async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .respond_with(ResponseTemplate::new(/*status*/ 403)) + .expect(/*requests*/ 1) + .mount(&server) + .await; + + let err = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, + SessionSource::Cli, + ) + .await + .expect_err("hard registration failure should return an error"); + + assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + assert!( + auth.stored_managed_chatgpt_agent_identity_record("account-123") + .is_none() + ); + Ok(()) +} + +#[tokio::test] +async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> anyhow::Result<()> { + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!( + "/v1/agent/{}/task/register", + record.agent_runtime_id + ))) + .respond_with(ResponseTemplate::new(/*status*/ 503)) + .expect(/*requests*/ 3) + .mount(&server) + .await; + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); + + let err = CodexAuth::from_agent_identity_jwt_with_authapi_base_url( + &agent_identity, + Some(&chatgpt_base_url), + &authapi_base_url, + /*auth_route_config*/ None, + ) + .await + .expect_err("agent identity jwt task retry exhaustion should fail"); + + assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + Ok(()) +} + #[tokio::test] async fn login_with_access_token_rejects_unsigned_jwt() { let dir = tempdir().unwrap(); @@ -290,7 +758,8 @@ async fn login_with_access_token_rejects_unsigned_jwt() { .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); super::login_with_access_token( dir.path(), @@ -299,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"); @@ -320,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"); @@ -348,6 +819,8 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -408,6 +881,8 @@ async fn loads_api_key_from_auth_json() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -455,8 +930,10 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { dir.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let managed = UnauthorizedRecovery { @@ -500,6 +977,8 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -519,6 +998,8 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("updated auth should parse"); @@ -824,6 +1305,7 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, + auth_route_config: None, } } @@ -874,7 +1356,7 @@ fn remove_access_token_env_var() -> EnvVarGuard { #[serial(auth_env)] async fn load_auth_reads_access_token_from_env() { let codex_home = tempdir().unwrap(); - let expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let mut expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type)) .expect("signed agent identity"); @@ -886,18 +1368,18 @@ async fn load_auth_reads_access_token_from_env() { .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; + expected_record.task_id = Some("task-123".to_string()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, @@ -905,6 +1387,8 @@ async fn load_auth_reads_access_token_from_env() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, + Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -914,7 +1398,7 @@ async fn load_auth_reads_access_token_from_env() { panic!("env auth should load as agent identity"); }; assert_eq!(agent_identity.record(), &expected_record); - assert_eq!(agent_identity.process_task_id(), "task-123"); + assert_eq!(agent_identity.run_task_id(), "task-123"); assert!( !get_auth_file(codex_home.path()).exists(), "env auth should not write auth.json" @@ -951,6 +1435,8 @@ async fn load_auth_reads_personal_access_token_from_env() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -997,14 +1483,14 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-workspace-mismatch"); - let manager = AuthManager::new_with_workspace_restriction( + let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, - /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1044,18 +1530,19 @@ 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"); - let manager = AuthManager::new_with_workspace_restriction( + let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, - /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1086,8 +1573,10 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -1119,6 +1608,8 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1215,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"); @@ -1226,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) @@ -1317,16 +1810,15 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); save_auth( codex_home.path(), &AuthDotJson { @@ -1334,7 +1826,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat openai_api_key: None, tokens: None, last_refresh: None, - agent_identity: Some(agent_identity), + agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)), personal_access_token: None, bedrock_api_key: None, }, @@ -1350,14 +1842,22 @@ 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(&config) - .await - .expect_err("expected workspace mismatch to error"); - assert!(err.to_string().contains(&format!( - "current credentials belong to {WORKSPACE_ID_DISALLOWED}" - ))); + let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url( + &config, + Some(&authapi_base_url), + ) + .await + .expect_err("expected workspace mismatch to error"); + let message = err.to_string(); + assert!( + message.contains(&format!( + "current credentials belong to {WORKSPACE_ID_DISALLOWED}" + )), + "{message}" + ); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" @@ -1426,12 +1926,31 @@ 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, } } +async fn mock_agent_task_registration( + server: &MockServer, + path_prefix: &str, + agent_runtime_id: &str, + task_id: &str, +) { + Mock::given(method("POST")) + .and(path(format!( + "{path_prefix}/v1/agent/{agent_runtime_id}/task/register" + ))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "task_id": task_id, + }))) + .expect(/*r*/ 1) + .mount(server) + .await; +} + fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result { fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?) } @@ -1563,19 +2082,23 @@ async fn assert_agent_identity_plan_alias( .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); - let auth = CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url)) - .await - .expect("agent identity auth"); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); + let auth = CodexAuth::from_agent_identity_jwt_with_authapi_base_url( + &jwt, + Some(&chatgpt_base_url), + &authapi_base_url, + /*auth_route_config*/ None, + ) + .await + .expect("agent identity auth"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type)); server.verify().await; @@ -1603,6 +2126,8 @@ async fn plan_type_maps_known_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1633,6 +2158,8 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1666,6 +2193,8 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1699,6 +2228,8 @@ async fn plan_type_maps_unknown_to_unknown() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1729,6 +2260,8 @@ async fn missing_plan_type_maps_to_unknown() { /*forced_chatgpt_workspace_id*/ None, /*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 a2dbc843065d..187b66de518c 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -60,8 +60,10 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -107,8 +109,10 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -130,8 +134,10 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 ee51edf9b56d..f72727bfca97 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) +} + +/// 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), + ) +} - build_reqwest_client_with_custom_ca(builder) +/// 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 79a9feebe918..3318f96872d0 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -17,9 +17,9 @@ use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use tokio::sync::Semaphore; use tokio::sync::watch; +use tracing::instrument; -use codex_agent_identity::decode_agent_identity_jwt; -use codex_agent_identity::fetch_agent_identity_jwks; +use codex_agent_identity::ChatGptEnvironment; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; @@ -27,19 +27,30 @@ use codex_protocol::config_types::ModelProviderAuthInfo; use super::access_token::CodexAccessToken; use super::access_token::classify_codex_access_token; +use super::agent_identity::ManagedChatGptAgentIdentityBinding; +use super::agent_identity::agent_identity_authapi_base_url; +use super::agent_identity::classify_bootstrap_error; +use super::agent_identity::record_matches_managed_chatgpt_binding; +use super::agent_identity::record_needs_task_registration; +use super::agent_identity::register_managed_chatgpt_agent_identity; +use super::agent_identity::require_agent_identity_authapi_base_url; +use super::agent_identity::verified_record_from_jwt; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::agent_identity::AgentIdentityAuthError; pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth; pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; +pub use crate::auth::storage::AgentIdentityStorage; pub use crate::auth::storage::AuthDotJson; pub use crate::auth::storage::AuthKeyringBackendKind; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; -use crate::default_client::build_reqwest_client; 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; @@ -50,6 +61,7 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::auth::RefreshTokenFailedError; use codex_protocol::auth::RefreshTokenFailedReason; +use codex_protocol::protocol::SessionSource; use serde_json::Value; use thiserror::Error; @@ -75,6 +87,15 @@ pub enum CodexAuth { BedrockApiKey(BedrockApiKeyAuth), } +/// Policy for resolving Agent Identity auth from a broader Codex auth snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentIdentityAuthPolicy { + /// Use Agent Identity auth only when the current auth is already Agent Identity. + JwtOnly, + /// Allow managed ChatGPT auth to register or reuse Agent Identity auth. + ChatGptAuth, +} + impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -116,7 +137,6 @@ const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = "Your access token could not be refreshed. Please log out and sign in again."; const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; -const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api"; const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; @@ -230,9 +250,10 @@ impl CodexAuth { auth_credentials_store_mode: AuthCredentialsStoreMode, 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(); - let client = create_client(); if auth_mode == ApiAuthMode::ApiKey { let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else { return Err(std::io::Error::other("API key auth is missing a key.")); @@ -240,12 +261,38 @@ impl CodexAuth { return Ok(Self::from_api_key(api_key)); } if auth_mode == ApiAuthMode::AgentIdentity { - let Some(agent_identity) = auth_dot_json.agent_identity else { + let Some(agent_identity) = auth_dot_json.agent_identity.clone() else { return Err(std::io::Error::other( - "agent identity auth is missing an agent identity token.", + "agent identity auth is missing agent identity auth material.", )); }; - return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; + let base_url = chatgpt_base_url + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) + .trim_end_matches('/') + .to_string(); + let agent_identity_authapi_base_url = + require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?; + match agent_identity { + AgentIdentityStorage::Jwt(jwt) => { + let auth = AgentIdentityAuth::from_jwt( + &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, + auth_route_config, + ) + .await?; + return Ok(Self::AgentIdentity(auth)); + } + } } if auth_mode == ApiAuthMode::PersonalAccessToken { let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { @@ -253,7 +300,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 { @@ -265,6 +313,7 @@ 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, @@ -296,7 +345,10 @@ 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(); load_auth( codex_home, /*enable_codex_api_key_env*/ false, @@ -304,6 +356,8 @@ impl CodexAuth { /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, keyring_backend_kind, + agent_identity_authapi_base_url.as_deref(), + auth_route_config, ) .await } @@ -311,18 +365,45 @@ 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 + } + + async fn from_agent_identity_jwt_with_authapi_base_url( + 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(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - let record = verified_agent_identity_record(jwt, &base_url).await?; - Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) + Ok(Self::AgentIdentity( + 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?, )) } @@ -437,8 +518,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), } } @@ -497,6 +578,97 @@ impl CodexAuth { self.get_current_auth_json().and_then(|t| t.tokens) } + fn stored_managed_chatgpt_agent_identity_record( + &self, + account_id: &str, + ) -> Option { + self.get_current_auth_json() + .and_then(|auth| auth.agent_identity) + .and_then(|identity| identity.as_record().cloned()) + .filter(|identity| identity.account_id == account_id) + } + + fn persist_managed_chatgpt_agent_identity_record( + &self, + record: AgentIdentityAuthRecord, + ) -> std::io::Result<()> { + if let Self::Chatgpt(chatgpt_auth) = self { + chatgpt_auth.persist_agent_identity_record(record)?; + } + Ok(()) + } + + async fn agent_identity_auth( + &self, + 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 { + Self::AgentIdentity(auth) => Ok(Some(auth.clone())), + Self::ApiKey(_) + | Self::ChatgptAuthTokens(_) + | Self::PersonalAccessToken(_) + | Self::BedrockApiKey(_) => Ok(None), + Self::Chatgpt(_) => { + if policy == AgentIdentityAuthPolicy::JwtOnly { + return Ok(None); + } + 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 + .map(Some) + } + } + } + + async fn ensure_managed_chatgpt_agent_identity( + &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 = + ManagedChatGptAgentIdentityBinding::from_auth(self, forced_chatgpt_workspace_id) + .ok_or_else(|| std::io::Error::other("ChatGPT auth is unavailable"))?; + + // JWT auth is loaded as CodexAuth::AgentIdentity; this path only reuses + // records created by the managed ChatGPT Agent Identity bootstrap. + if let Some(record) = self.stored_managed_chatgpt_agent_identity_record(&binding.account_id) + && 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, + 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())?; + } + return Ok(auth); + } + + let auth = register_managed_chatgpt_agent_identity( + binding, + agent_identity_authapi_base_url, + session_source, + auth_route_config, + ) + .await?; + self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; + Ok(auth) + } + /// Consider this private to integration tests. pub fn create_dummy_chatgpt_auth_for_testing() -> Self { let auth_dot_json = AuthDotJson { @@ -514,10 +686,9 @@ impl CodexAuth { bedrock_api_key: None, }; - let client = create_client(); let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client, + client: create_client(), }; let dummy_auth_id = NEXT_DUMMY_AUTH_ID.fetch_add(1, Ordering::Relaxed); let storage = create_auth_storage( @@ -535,6 +706,43 @@ impl CodexAuth { } } +impl ManagedChatGptAgentIdentityBinding { + fn from_auth(auth: &CodexAuth, forced_workspace_id: Option>) -> Option { + if !auth.is_chatgpt_auth() { + return None; + } + + let token_data = auth.get_token_data().ok()?; + let forced_workspace_id = + forced_workspace_id + .as_deref() + .and_then(|workspace_ids| match workspace_ids { + [workspace_id] if !workspace_id.is_empty() => Some(workspace_id.clone()), + _ => None, + }); + let account_id = forced_workspace_id + .or(token_data + .account_id + .clone() + .filter(|value| !value.is_empty())) + .or(token_data.id_token.chatgpt_account_id.clone())?; + let chatgpt_user_id = token_data + .id_token + .chatgpt_user_id + .clone() + .filter(|value| !value.is_empty())?; + + Some(Self { + account_id, + chatgpt_user_id, + 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, + }) + } +} + impl ChatgptAuth { fn current_auth_json(&self) -> Option { #[expect(clippy::unwrap_used)] @@ -552,6 +760,31 @@ impl ChatgptAuth { fn client(&self) -> &CodexHttpClient { &self.state.client } + + fn persist_agent_identity_record( + &self, + record: AgentIdentityAuthRecord, + ) -> std::io::Result<()> { + persist_agent_identity_record(&self.state.auth_dot_json, &self.storage, record) + } +} + +fn persist_agent_identity_record( + auth_dot_json: &Arc>>, + storage: &Arc, + record: AgentIdentityAuthRecord, +) -> std::io::Result<()> { + let mut guard = auth_dot_json + .lock() + .map_err(|_| std::io::Error::other("failed to lock auth state"))?; + let mut auth = storage + .load()? + .or_else(|| guard.clone()) + .ok_or_else(|| std::io::Error::other("auth data is not available"))?; + auth.agent_identity = Some(AgentIdentityStorage::Record(record)); + storage.save(&auth)?; + *guard = Some(auth); + Ok(()) } pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; @@ -580,18 +813,6 @@ fn read_non_empty_env_var(key: &str) -> Option { .filter(|value| !value.is_empty()) } -async fn verified_agent_identity_record( - jwt: &str, - chatgpt_base_url: &str, -) -> std::io::Result { - AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; - let jwks = fetch_agent_identity_jwks(&build_reqwest_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)?; - Ok(claims.into()) -} - /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// if a file was removed, `Ok(false)` if no auth file was present. pub fn logout( @@ -611,6 +832,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, @@ -623,7 +845,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( @@ -665,10 +887,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 @@ -684,16 +907,16 @@ pub async fn login_with_access_token( } CodexAccessToken::AgentIdentityJwt(jwt) => { let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - verified_agent_identity_record(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, tokens: None, last_refresh: None, - agent_identity: Some(jwt.to_string()), + agent_identity: Some(AgentIdentityStorage::Jwt(jwt.to_string())), personal_access_token: None, bedrock_api_key: None, } @@ -730,6 +953,7 @@ pub async fn login_with_agent_identity( /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, keyring_backend_kind, + /*auth_route_config*/ None, ) .await } @@ -795,9 +1019,24 @@ 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(); + enforce_login_restrictions_with_agent_identity_authapi_base_url( + config, + agent_identity_authapi_base_url.as_deref(), + ) + .await +} + +async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( + config: &AuthConfig, + agent_identity_authapi_base_url: Option<&str>, +) -> std::io::Result<()> { let Some(auth) = load_auth( &config.codex_home, /*enable_codex_api_key_env*/ true, @@ -805,6 +1044,8 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< /*forced_chatgpt_workspace_id*/ None, config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, + agent_identity_authapi_base_url, + config.auth_route_config.as_ref(), ) .await? else { @@ -940,6 +1181,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, @@ -947,6 +1189,8 @@ async fn load_auth( forced_chatgpt_workspace_id: Option<&[String]>, 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() { @@ -967,6 +1211,8 @@ async fn load_auth( AuthCredentialsStoreMode::Ephemeral, chatgpt_base_url, keyring_backend_kind, + agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -978,15 +1224,20 @@ 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))) } CodexAccessToken::AgentIdentityJwt(jwt) => { - CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) - .await - .map(Some) + CodexAuth::from_agent_identity_jwt_with_authapi_base_url( + jwt, + chatgpt_base_url, + require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, + auth_route_config, + ) } + .await + .map(Some), }; } @@ -1012,6 +1263,8 @@ async fn load_auth( auth_credentials_store_mode, chatgpt_base_url, keyring_backend_kind, + agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1057,7 +1310,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 @@ -1518,9 +1770,12 @@ pub struct AuthManager { keyring_backend_kind: AuthKeyringBackendKind, forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, + agent_identity_authapi_base_url: Option, refresh_lock: Semaphore, + agent_identity_lock: Semaphore, external_auth: RwLock>>, account_pool: Option>, + auth_route_config: Option, } /// Configuration view required to construct a shared [`AuthManager`]. @@ -1549,6 +1804,11 @@ pub trait AuthManagerConfig { fn account_pool(&self) -> Option { None } + + /// Returns route-selection settings for auth-owned clients. + fn auth_route_config(&self) -> Option { + None + } } impl Debug for AuthManager { @@ -1567,43 +1827,33 @@ 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()) .field("has_account_pool", &self.account_pool.is_some()) .finish_non_exhaustive() } } +fn default_agent_identity_authapi_base_url() -> Option { + agent_identity_authapi_base_url(/*chatgpt_base_url*/ None).ok() +} + impl AuthManager { /// Create a new manager loading the initial auth using the provided /// preferred auth method. Errors loading auth are swallowed; `auth()` will /// simply return `None` in that case so callers can treat it as an /// unauthenticated state. pub async fn new( - codex_home: PathBuf, - enable_codex_api_key_env: bool, - auth_credentials_store_mode: AuthCredentialsStoreMode, - chatgpt_base_url: Option, - keyring_backend_kind: AuthKeyringBackendKind, - ) -> Self { - Self::new_with_workspace_restriction( - codex_home, - enable_codex_api_key_env, - auth_credentials_store_mode, - /*forced_chatgpt_workspace_id*/ None, - chatgpt_base_url, - keyring_backend_kind, - ) - .await - } - - async fn new_with_workspace_restriction( codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, 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(); let managed_auth = load_auth( &codex_home, enable_codex_api_key_env, @@ -1611,6 +1861,8 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), chatgpt_base_url.as_deref(), keyring_backend_kind, + agent_identity_authapi_base_url.as_deref(), + auth_route_config.as_ref(), ) .await .ok() @@ -1628,9 +1880,12 @@ impl AuthManager { keyring_backend_kind, forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id), chatgpt_base_url, + agent_identity_authapi_base_url, refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), account_pool: None, + auth_route_config, } } @@ -1651,9 +1906,12 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), account_pool: None, + auth_route_config: None, }) } @@ -1673,9 +1931,45 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), account_pool: None, + auth_route_config: None, + }) + } + + /// Create an AuthManager with a specific CodexAuth and Agent Identity AuthAPI base URL, for testing only. + #[doc(hidden)] + pub fn from_auth_for_testing_with_agent_identity_authapi_base_url( + auth: CodexAuth, + agent_identity_authapi_base_url: String, + ) -> Arc { + let cached = CachedAuth { + auth: Some(auth), + permanent_refresh_failure: None, + }; + let (auth_change_tx, _auth_change_rx) = watch::channel(0); + Arc::new(Self { + codex_home: PathBuf::from("non-existent"), + inner: RwLock::new(cached), + auth_change_tx, + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::default(), + forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, + agent_identity_authapi_base_url: Some( + agent_identity_authapi_base_url + .trim_end_matches('/') + .to_string(), + ), + refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), + external_auth: RwLock::new(None), + account_pool: None, + auth_route_config: None, }) } @@ -1693,11 +1987,14 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc )), account_pool: None, + auth_route_config: None, }) } @@ -1731,6 +2028,7 @@ impl AuthManager { /// Current cached auth (clone). May be `None` if not logged in or load failed. /// For managed ChatGPT auth that needs a proactive refresh, first performs /// a guarded reload and then refreshes only if the on-disk auth is unchanged. + #[instrument(level = "trace", skip_all)] pub async fn auth(&self) -> Option { if let Some(auth) = self.resolve_external_api_key_auth().await { return Some(auth); @@ -1790,6 +2088,40 @@ impl AuthManager { self.auth_cached_unpooled() } + pub async fn agent_identity_auth( + &self, + policy: AgentIdentityAuthPolicy, + session_source: SessionSource, + ) -> std::io::Result> { + let Some(auth) = self.auth().await else { + return Ok(None); + }; + if policy == AgentIdentityAuthPolicy::ChatGptAuth && matches!(auth, CodexAuth::Chatgpt(_)) { + let _bootstrap_permit = self + .agent_identity_lock + .acquire() + .await + .map_err(std::io::Error::other)?; + return auth + .agent_identity_auth( + policy, + self.agent_identity_authapi_base_url.as_deref(), + self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), + session_source, + ) + .await; + } + auth.agent_identity_auth( + policy, + self.agent_identity_authapi_base_url.as_deref(), + self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), + session_source, + ) + .await + } + /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub async fn reload(&self) -> bool { @@ -1892,6 +2224,8 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), 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() @@ -2035,16 +2369,20 @@ impl AuthManager { codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Arc { Arc::new( Self::new( codex_home, enable_codex_api_key_env, auth_credentials_store_mode, + forced_chatgpt_workspace_id, chatgpt_base_url, keyring_backend_kind, + auth_route_config, ) .await, ) @@ -2059,13 +2397,14 @@ impl AuthManager { let auth_credentials_store_mode = config.cli_auth_credentials_store_mode(); let chatgpt_base_url = Some(config.chatgpt_base_url()); let account_pool_config = config.account_pool(); - let mut manager = Self::new_with_workspace_restriction( + let mut manager = Self::new( codex_home.clone(), enable_codex_api_key_env, auth_credentials_store_mode, config.forced_chatgpt_workspace_id(), chatgpt_base_url.clone(), config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await; if let Some(account_pool_config) = account_pool_config { @@ -2074,6 +2413,7 @@ impl AuthManager { account_pool_config, auth_credentials_store_mode, chatgpt_base_url, + config.auth_route_config(), ) .await .map(Arc::new); @@ -2237,7 +2577,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 b99092f51f19..ea75648d1bbf 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 ac6ee12265eb..b05edb068bf3 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 22de353d3a86..0f7e313c1cb1 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 ebec06417065..b5ae190551b1 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -51,7 +51,7 @@ pub struct AuthDotJson { pub last_refresh: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_identity: Option, + pub agent_identity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub personal_access_token: Option, @@ -60,15 +60,67 @@ pub struct AuthDotJson { pub bedrock_api_key: Option, } +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum AgentIdentityStorage { + Jwt(String), + Record(AgentIdentityAuthRecord), +} + +impl AgentIdentityStorage { + pub fn has_auth_material(&self) -> bool { + match self { + Self::Jwt(jwt) => !jwt.trim().is_empty(), + Self::Record(record) => { + !record.agent_runtime_id.trim().is_empty() + && !record.agent_private_key.trim().is_empty() + } + } + } + + pub(crate) fn as_record(&self) -> Option<&AgentIdentityAuthRecord> { + match self { + Self::Jwt(_) => None, + Self::Record(record) => Some(record), + } + } +} + #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] pub struct AgentIdentityAuthRecord { pub agent_runtime_id: String, 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 { @@ -90,6 +142,7 @@ impl From for AgentIdentityAuthRecord { email: claims.email, plan_type: claims.plan_type.into(), chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp, + task_id: None, } } } diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index 72d59b660037..647af79b656a 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -81,7 +81,7 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { openai_api_key: None, tokens: None, last_refresh: None, - agent_identity: Some(agent_identity), + agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)), personal_access_token: None, bedrock_api_key: None, }; @@ -93,6 +93,116 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let 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: Some("user@example.com".to_string()), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: Some("task-id".to_string()), + }; + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(record)), + personal_access_token: None, + bedrock_api_key: None, + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + 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()?; @@ -139,8 +249,8 @@ async fn file_storage_loads_agent_identity_as_jwt() -> anyhow::Result<()> { let loaded = storage.load()?; assert_eq!( - loaded.expect("auth should load").agent_identity.as_deref(), - Some(agent_identity_jwt.as_str()) + loaded.expect("auth should load").agent_identity, + Some(AgentIdentityStorage::Jwt(agent_identity_jwt)) ); Ok(()) } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 6f18c5984c97..70e6dff22538 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 4b2c8aa7b30e..b814707bb9ae 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; @@ -29,6 +30,7 @@ pub use auth::AccountPoolUsageBucket; pub use auth::AccountPoolUsageRefreshPoolReport; pub use auth::AccountPoolUsageRefreshProblem; pub use auth::AccountPoolUsageRefreshReport; +pub use auth::AgentIdentityAuthPolicy; pub use auth::AuthConfig; pub use auth::AuthDotJson; pub use auth::AuthKeyringBackendKind; @@ -65,4 +67,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 000000000000..bf7dfbeb4d8c --- /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 67d5e8c663a1..c450da0f68d5 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/account_pool__selection.rs b/codex-rs/login/tests/suite/account_pool__selection.rs index c18eaac8cec0..6cc70f2458db 100644 --- a/codex-rs/login/tests/suite/account_pool__selection.rs +++ b/codex-rs/login/tests/suite/account_pool__selection.rs @@ -332,6 +332,7 @@ async fn load_balance_pool(codex_home: &Path) -> Result { }, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .context("account pool should be enabled") diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 4065722298f1..9c5eb936b190 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -1217,8 +1217,10 @@ impl RefreshTokenTestContext { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 78adc84c24ee..83cae8af5fc3 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 3d7c5a180b21..6f7b4e4f83a3 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 5df73e932fb5..e8f9981a8a7f 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?; @@ -201,8 +204,10 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*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 c4ec218dc9cc..cbc113ff79e0 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -220,6 +220,7 @@ async fn run_codex_tool_session_inner( let approval_id = ev.effective_approval_id(); let ExecApprovalRequestEvent { turn_id: _, + environment_id: _, started_at_ms: _, command, cwd, @@ -265,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/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 4a4ff054beaf..cb14fec3e2d6 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -1,4 +1,5 @@ //! Prototype MCP server. +#![recursion_limit = "256"] #![deny(clippy::print_stdout, clippy::print_stderr)] use std::io::ErrorKind; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index ce19575a9c60..5f36091feef2 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -78,6 +78,7 @@ impl MessageProcessor { state_db.clone(), installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, )); Self { outgoing, diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 775e8988745c..5b5ae81662f4 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, ] @@ -742,6 +742,7 @@ mod tests { let serialized = job::serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem( ResponseItem::FunctionCallOutput { + id: None, call_id: "call_123".to_string(), output: codex_protocol::models::FunctionCallOutputPayload { body: codex_protocol::models::FunctionCallOutputBody::Text( @@ -749,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/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 8dae6ca61704..0835e155205b 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -235,6 +235,7 @@ impl MemoryStartupContext { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), /*beta_features_header*/ None, + config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); @@ -310,9 +311,11 @@ impl MemoryStartupContext { thread_source: Some(ThreadSource::MemoryConsolidation), dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 8cc039a09bad..c45347fddd44 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -697,7 +697,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/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 4b2c20d5355f..29fa35ee59f0 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -17,6 +17,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; 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::Result; use codex_protocol::openai_models::ModelsResponse; @@ -146,8 +147,13 @@ impl ModelProvider for AmazonBedrockModelProvider { } fn account_state(&self) -> ProviderAccountResult { + let credential_source = if self.managed_auth().is_some() { + AmazonBedrockCredentialSource::CodexManaged + } else { + AmazonBedrockCredentialSource::AwsManaged + }; Ok(ProviderAccountState { - account: Some(ProviderAccount::AmazonBedrock), + account: Some(ProviderAccount::AmazonBedrock { credential_source }), requires_openai_auth: false, }) } @@ -241,6 +247,15 @@ mod tests { provider.auth().await, Some(CodexAuth::BedrockApiKey(managed_auth)) ); + assert_eq!( + provider.account_state(), + Ok(ProviderAccountState { + account: Some(ProviderAccount::AmazonBedrock { + credential_source: AmazonBedrockCredentialSource::CodexManaged, + }), + requires_openai_auth: false, + }) + ); assert_eq!( provider .runtime_base_url() @@ -270,6 +285,15 @@ mod tests { assert!(provider.auth_manager().is_none()); assert_eq!(provider.auth().await, None); + assert_eq!( + provider.account_state(), + Ok(ProviderAccountState { + account: Some(ProviderAccount::AmazonBedrock { + credential_source: AmazonBedrockCredentialSource::AwsManaged, + }), + requires_openai_auth: false, + }) + ); } #[test] diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 8f5e02b1757b..555312027401 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; -use codex_agent_identity::AgentTaskAuthorizationTarget; use codex_agent_identity::authorization_header_for_agent_task; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; @@ -30,10 +29,7 @@ impl AuthProvider for AgentIdentityAuthProvider { agent_runtime_id: &record.agent_runtime_id, private_key_pkcs8_base64: &record.agent_private_key, }, - AgentTaskAuthorizationTarget { - agent_runtime_id: &record.agent_runtime_id, - task_id: self.auth.process_task_id(), - }, + self.auth.run_task_id(), ) .map_err(std::io::Error::other); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 5901bc053cb5..bb06cbca4a84 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -65,10 +65,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!( @@ -365,14 +362,12 @@ 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()? @@ -438,6 +433,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; @@ -643,7 +639,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( @@ -653,7 +649,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, + }) ); } @@ -702,7 +704,10 @@ mod tests { assert_eq!( provider.account_state(), Ok(ProviderAccountState { - account: Some(ProviderAccount::AmazonBedrock), + account: Some(ProviderAccount::AmazonBedrock { + credential_source: + codex_protocol::account::AmazonBedrockCredentialSource::AwsManaged, + }), requires_openai_auth: false, }) ); diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index cba3e44d39b4..4afb9bb8f0e0 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -241,6 +241,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 cd32ff92b22e..cc6a8de7104f 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 001469aa9a65..2ff7cc03b459 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,38 +157,203 @@ 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 + 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 { + path, + startup_env_values, + }) +} + +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(); - let trust_bundle = build_managed_ca_trust_bundle(cert_path)?; - let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?; + .collect() +} - Ok(ManagedMitmCaTrustBundle { - path, +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 build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { - let mut trust_bundle = String::new(); +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/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index f20c01b906fa..3f02ad3fc07d 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -87,6 +87,7 @@ pub async fn run_http_proxy( state: Arc, addr: SocketAddr, policy_decider: Option>, + environment_id: Option, ) -> Result<()> { let listener = TcpListener::build() .bind(addr) @@ -99,23 +100,25 @@ pub async fn run_http_proxy( .map_err(anyhow::Error::from) .with_context(|| format!("bind HTTP proxy: {addr}"))?; - run_http_proxy_with_listener(state, listener, policy_decider).await + run_http_proxy_with_listener(state, listener, policy_decider, environment_id).await } pub async fn run_http_proxy_with_std_listener( state: Arc, listener: StdTcpListener, policy_decider: Option>, + environment_id: Option, ) -> Result<()> { let listener = TcpListener::try_from(listener).context("convert std listener to HTTP proxy listener")?; - run_http_proxy_with_listener(state, listener, policy_decider).await + run_http_proxy_with_listener(state, listener, policy_decider, environment_id).await } async fn run_http_proxy_with_listener( state: Arc, listener: TcpListener, policy_decider: Option>, + environment_id: Option, ) -> Result<()> { ensure_rustls_crypto_provider(); @@ -133,7 +136,10 @@ async fn run_http_proxy_with_listener( MethodMatcher::CONNECT, service_fn({ let policy_decider = policy_decider.clone(); - move |req| http_connect_accept(policy_decider.clone(), req) + let environment_id = environment_id.clone(); + move |req| { + http_connect_accept(policy_decider.clone(), environment_id.clone(), req) + } }), service_fn(http_connect_proxy), ), @@ -141,7 +147,8 @@ async fn run_http_proxy_with_listener( ) .into_layer(service_fn({ let policy_decider = policy_decider.clone(); - move |req| http_plain_proxy(policy_decider.clone(), req) + let environment_id = environment_id.clone(); + move |req| http_plain_proxy(policy_decider.clone(), environment_id.clone(), req) })), ); @@ -155,6 +162,7 @@ async fn run_http_proxy_with_listener( async fn http_connect_accept( policy_decider: Option>, + environment_id: Option, mut req: Request, ) -> Result<(Response, Request), Response> { let app_state = req @@ -200,6 +208,7 @@ async fn http_connect_accept( protocol: NetworkProtocol::HttpsConnect, host: host.clone(), port: authority.port, + environment_id, client_addr: client.clone(), method: Some("CONNECT".to_string()), command: None, @@ -478,6 +487,7 @@ async fn forward_connect_tunnel( async fn http_plain_proxy( policy_decider: Option>, + environment_id: Option, mut req: Request, ) -> Result { let app_state = match req.extensions().get::>().cloned() { @@ -683,6 +693,7 @@ async fn http_plain_proxy( protocol: NetworkProtocol::Http, host: host.clone(), port, + environment_id, client_addr: client.clone(), method: Some(req.method().as_str().to_string()), command: None, @@ -1050,6 +1061,7 @@ mod tests { use std::net::Ipv4Addr; use std::net::TcpListener as StdTcpListener; use std::sync::Arc; + use std::sync::Mutex; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener as TokioTcpListener; @@ -1074,9 +1086,11 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_connect_accept(/*policy_decider*/ None, req) - .await - .unwrap_err(); + let response = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response.headers().get("x-proxy-error").unwrap(), @@ -1104,10 +1118,51 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let (response, _request) = http_connect_accept(/*policy_decider*/ None, req) - .await + let (response, _request) = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn http_connect_accept_passes_environment_id_to_decider() { + let state = Arc::new(network_proxy_state_for_policy( + NetworkProxySettings::default(), + )); + let seen_environment_id = Arc::new(Mutex::new(None)); + let decider: Arc = Arc::new({ + let seen_environment_id = seen_environment_id.clone(); + move |request: NetworkPolicyRequest| { + *seen_environment_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = request.environment_id; + async { NetworkDecision::Allow } + } + }); + + let mut req = Request::builder() + .method(Method::CONNECT) + .uri("https://example.com:443") + .header("host", "example.com:443") + .body(Body::empty()) .unwrap(); + req.extensions_mut().insert(state); + + let (response, _request) = + http_connect_accept(Some(decider), Some("remote".to_string()), req) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + seen_environment_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_deref(), + Some("remote") + ); } #[tokio::test] @@ -1136,9 +1191,11 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_connect_accept(/*policy_decider*/ None, req) - .await - .unwrap_err(); + let response = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response.headers().get("x-proxy-error").unwrap(), @@ -1175,7 +1232,7 @@ mod tests { .local_addr() .expect("proxy listener should expose local addr"); let proxy_task = tokio::spawn(run_http_proxy_with_std_listener( - state, listener, /*policy_decider*/ None, + state, listener, /*policy_decider*/ None, /*environment_id*/ None, )); let mut stream = tokio::net::TcpStream::connect(proxy_addr) @@ -1226,9 +1283,11 @@ mod tests { .expect("request should build"); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req) - .await - .unwrap(); + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( @@ -1251,9 +1310,11 @@ mod tests { .expect("request should build"); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req) - .await - .unwrap(); + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); if cfg!(target_os = "macos") { assert_eq!(response.status(), StatusCode::FORBIDDEN); @@ -1283,9 +1344,11 @@ mod tests { .expect("request should build"); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req) - .await - .unwrap(); + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } @@ -1307,9 +1370,11 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_connect_accept(/*policy_decider*/ None, req) - .await - .unwrap_err(); + let response = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response.headers().get("x-proxy-error").unwrap(), @@ -1330,7 +1395,10 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req).await; + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await; assert_eq!(response.unwrap().status(), StatusCode::BAD_REQUEST); } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index e80b154a5eae..c8950f1f5592 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 345c5b503296..70175ca70602 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 000000000000..6c8d45005db8 --- /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/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index 18bca2c9169f..e5792a07c0ee 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -79,6 +79,7 @@ pub struct NetworkPolicyRequest { pub protocol: NetworkProtocol, pub host: String, pub port: u16, + pub environment_id: Option, pub client_addr: Option, pub method: Option, pub command: Option, @@ -89,6 +90,7 @@ pub struct NetworkPolicyRequestArgs { pub protocol: NetworkProtocol, pub host: String, pub port: u16, + pub environment_id: Option, pub client_addr: Option, pub method: Option, pub command: Option, @@ -101,6 +103,7 @@ impl NetworkPolicyRequest { protocol, host, port, + environment_id, client_addr, method, command, @@ -110,6 +113,7 @@ impl NetworkPolicyRequest { protocol, host, port, + environment_id, client_addr, method, command, @@ -625,6 +629,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: None, command: None, @@ -686,6 +691,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "blocked.com".to_string(), port: 80, + environment_id: None, client_addr: Some("127.0.0.1:1234".to_string()), method: Some("GET".to_string()), command: None, @@ -729,6 +735,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, @@ -779,6 +786,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, @@ -865,6 +873,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "127.0.0.1".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c3685a4310aa..13998dd447ee 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -222,11 +222,13 @@ impl NetworkProxyBuilder { http_addr, socks_addr, socks_enabled: current_cfg.network.enable_socks5, + socks5_udp_enabled: current_cfg.network.enable_socks5_udp, runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config( ¤t_cfg, )?)), reserved_listeners, policy_decider: self.policy_decider, + environment_proxies: Arc::new(Mutex::new(HashMap::new())), }) } } @@ -306,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 @@ -323,15 +322,29 @@ impl NetworkProxyRuntimeSettings { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EnvironmentProxyAddrs { + http_addr: SocketAddr, + socks_addr: SocketAddr, +} + +struct EnvironmentProxy { + addrs: EnvironmentProxyAddrs, + http_task: JoinHandle>, + socks_task: Option>>, +} + #[derive(Clone)] pub struct NetworkProxy { state: Arc, http_addr: SocketAddr, socks_addr: SocketAddr, socks_enabled: bool, + socks5_udp_enabled: bool, runtime_settings: Arc>, reserved_listeners: Option>, policy_decider: Option>, + environment_proxies: Arc>>, } impl std::fmt::Debug for NetworkProxy { @@ -640,20 +653,135 @@ impl NetworkProxy { }) } - pub fn apply_to_env(&self, env: &mut HashMap) { + fn apply_to_env_for_addrs( + &self, + env: &mut HashMap, + addrs: EnvironmentProxyAddrs, + ) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; // managed MITM CA vars preserve child-scoped overrides after proxy startup. apply_proxy_env_overrides( env, - self.http_addr, - self.socks_addr, + addrs.http_addr, + addrs.socks_addr, self.socks_enabled, runtime_settings.allow_local_binding, runtime_settings.mitm_ca_trust_bundle.as_ref(), ); } + pub fn apply_to_env(&self, env: &mut HashMap) { + self.apply_to_env_for_addrs( + env, + EnvironmentProxyAddrs { + http_addr: self.http_addr, + socks_addr: self.socks_addr, + }, + ); + } + + pub fn apply_to_env_for_environment( + &self, + env: &mut HashMap, + environment_id: &str, + ) -> Result<()> { + let addrs = self.environment_proxy_addrs(environment_id)?; + self.apply_to_env_for_addrs(env, addrs); + Ok(()) + } + + pub fn apply_to_env_for_optional_environment( + &self, + env: &mut HashMap, + environment_id: Option<&str>, + ) -> Result<()> { + match environment_id { + Some(environment_id) => self.apply_to_env_for_environment(env, environment_id), + None => { + self.apply_to_env(env); + Ok(()) + } + } + } + + fn environment_proxy_addrs(&self, environment_id: &str) -> Result { + let mut proxies = self + .environment_proxies + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(proxy) = proxies.get(environment_id) { + return Ok(proxy.addrs); + } + + let runtime = tokio::runtime::Handle::try_current().with_context(|| { + format!("failed to create network proxy for environment `{environment_id}`") + })?; + let listeners = + reserve_loopback_ephemeral_listeners(self.socks_enabled).with_context(|| { + format!("failed to reserve network proxy for environment `{environment_id}`") + })?; + let http_addr = listeners.http_addr().with_context(|| { + format!("failed to read HTTP proxy address for environment `{environment_id}`") + })?; + let socks_addr = listeners.socks_addr(self.socks_addr).with_context(|| { + format!("failed to read SOCKS proxy address for environment `{environment_id}`") + })?; + let addrs = EnvironmentProxyAddrs { + http_addr, + socks_addr, + }; + let ReservedListenerSet { + http_listener, + socks_listener, + } = listeners; + + let environment_id = environment_id.to_string(); + let http_state = self.state.clone(); + let http_decider = self.policy_decider.clone(); + let http_environment_id = Some(environment_id.clone()); + let http_task = runtime.spawn(async move { + http_proxy::run_http_proxy_with_std_listener( + http_state, + http_listener, + http_decider, + http_environment_id, + ) + .await + }); + + let socks_task = if self.socks_enabled { + let socks_state = self.state.clone(); + let socks_decider = self.policy_decider.clone(); + let socks_environment_id = Some(environment_id.clone()); + let socks5_udp_enabled = self.socks5_udp_enabled; + socks_listener.map(|listener| { + runtime.spawn(async move { + socks5::run_socks5_with_std_listener( + socks_state, + listener, + socks_decider, + socks_environment_id, + socks5_udp_enabled, + ) + .await + }) + }) + } else { + None + }; + + proxies.insert( + environment_id, + EnvironmentProxy { + addrs, + http_task, + socks_task, + }, + ); + Ok(addrs) + } + pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> { let current_cfg = self.state.current_cfg().await?; anyhow::ensure!( @@ -717,10 +845,23 @@ impl NetworkProxy { let http_task = tokio::spawn(async move { match http_listener { Some(listener) => { - http_proxy::run_http_proxy_with_std_listener(http_state, listener, http_decider) - .await + http_proxy::run_http_proxy_with_std_listener( + http_state, + listener, + http_decider, + /*environment_id*/ None, + ) + .await + } + None => { + http_proxy::run_http_proxy( + http_state, + http_addr, + http_decider, + /*environment_id*/ None, + ) + .await } - None => http_proxy::run_http_proxy(http_state, http_addr, http_decider).await, } }); @@ -736,6 +877,7 @@ impl NetworkProxy { socks_state, listener, socks_decider, + /*environment_id*/ None, enable_socks5_udp, ) .await @@ -745,6 +887,7 @@ impl NetworkProxy { socks_state, socks_addr, socks_decider, + /*environment_id*/ None, enable_socks5_udp, ) .await @@ -758,6 +901,7 @@ impl NetworkProxy { Ok(NetworkProxyHandle { http_task: Some(http_task), socks_task, + environment_proxies: self.environment_proxies.clone(), completed: false, }) } @@ -766,6 +910,7 @@ impl NetworkProxy { pub struct NetworkProxyHandle { http_task: Option>>, socks_task: Option>>, + environment_proxies: Arc>>, completed: bool, } @@ -774,6 +919,7 @@ impl NetworkProxyHandle { Self { http_task: Some(tokio::spawn(async { Ok(()) })), socks_task: None, + environment_proxies: Arc::new(Mutex::new(HashMap::new())), completed: true, } } @@ -787,6 +933,7 @@ impl NetworkProxyHandle { None => None, }; self.completed = true; + abort_environment_proxies(self.environment_proxies.clone()).await; http_result??; if let Some(socks_result) = socks_result { socks_result??; @@ -796,6 +943,7 @@ impl NetworkProxyHandle { pub async fn shutdown(mut self) -> Result<()> { abort_tasks(self.http_task.take(), self.socks_task.take()).await; + abort_environment_proxies(self.environment_proxies.clone()).await; self.completed = true; Ok(()) } @@ -816,6 +964,21 @@ async fn abort_tasks( abort_task(socks_task).await; } +async fn abort_environment_proxies( + environment_proxies: Arc>>, +) { + let proxies = { + let mut guard = environment_proxies + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.drain().map(|(_, proxy)| proxy).collect::>() + }; + for proxy in proxies { + abort_task(Some(proxy.http_task)).await; + abort_task(proxy.socks_task).await; + } +} + impl Drop for NetworkProxyHandle { fn drop(&mut self) { if self.completed { @@ -823,8 +986,10 @@ impl Drop for NetworkProxyHandle { } let http_task = self.http_task.take(); let socks_task = self.socks_task.take(); + let environment_proxies = self.environment_proxies.clone(); tokio::spawn(async move { abort_tasks(http_task, socks_task).await; + abort_environment_proxies(environment_proxies).await; }); } } @@ -905,6 +1070,33 @@ mod tests { ); } + #[tokio::test] + async fn apply_to_env_for_environment_uses_distinct_proxy_ports() -> Result<()> { + let state = Arc::new(network_proxy_state_for_policy( + NetworkProxySettings::default(), + )); + let proxy = NetworkProxy::builder().state(state).build().await?; + let handle = proxy.run().await?; + + let mut local_env = HashMap::new(); + proxy.apply_to_env_for_environment(&mut local_env, "local")?; + let mut remote_env = HashMap::new(); + proxy.apply_to_env_for_environment(&mut remote_env, "remote")?; + + assert_ne!(local_env.get("HTTP_PROXY"), remote_env.get("HTTP_PROXY")); + assert_ne!( + local_env.get("HTTP_PROXY"), + Some(&format!("http://{}", proxy.http_addr())) + ); + assert_ne!( + remote_env.get("HTTP_PROXY"), + Some(&format!("http://{}", proxy.http_addr())) + ); + + handle.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() { let settings = NetworkProxySettings { diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 6e600d2025ab..5cc7b2eed39f 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -64,6 +64,7 @@ pub async fn run_socks5( state: Arc, addr: SocketAddr, policy_decider: Option>, + environment_id: Option, enable_socks5_udp: bool, ) -> Result<()> { let listener = TcpListener::build() @@ -74,24 +75,40 @@ pub async fn run_socks5( .map_err(anyhow::Error::from) .with_context(|| format!("bind SOCKS5 proxy: {addr}"))?; - run_socks5_with_listener(state, listener, policy_decider, enable_socks5_udp).await + run_socks5_with_listener( + state, + listener, + policy_decider, + environment_id, + enable_socks5_udp, + ) + .await } pub async fn run_socks5_with_std_listener( state: Arc, listener: StdTcpListener, policy_decider: Option>, + environment_id: Option, enable_socks5_udp: bool, ) -> Result<()> { let listener = TcpListener::try_from(listener).context("convert std listener to SOCKS5 proxy listener")?; - run_socks5_with_listener(state, listener, policy_decider, enable_socks5_udp).await + run_socks5_with_listener( + state, + listener, + policy_decider, + environment_id, + enable_socks5_udp, + ) + .await } async fn run_socks5_with_listener( state: Arc, listener: TcpListener, policy_decider: Option>, + environment_id: Option, enable_socks5_udp: bool, ) -> Result<()> { let addr = listener @@ -115,10 +132,12 @@ async fn run_socks5_with_listener( let tcp_connector = TargetCheckedTcpConnector::new(state.clone()); let policy_tcp_connector = service_fn({ let policy_decider = policy_decider.clone(); + let environment_id = environment_id.clone(); move |req: TcpRequest| { let tcp_connector = tcp_connector.clone(); let policy_decider = policy_decider.clone(); - async move { handle_socks5_tcp(req, tcp_connector, policy_decider).await } + let environment_id = environment_id.clone(); + async move { handle_socks5_tcp(req, tcp_connector, policy_decider, environment_id).await } } }); @@ -131,13 +150,18 @@ async fn run_socks5_with_listener( if enable_socks5_udp { let udp_state = state.clone(); let udp_decider = policy_decider.clone(); - let udp_relay = DefaultUdpRelay::default().with_async_inspector(service_fn({ - move |request: RelayRequest| { - let udp_state = udp_state.clone(); - let udp_decider = udp_decider.clone(); - async move { inspect_socks5_udp(request, udp_state, udp_decider).await } - } - })); + let udp_relay = + DefaultUdpRelay::default().with_async_inspector(service_fn({ + let environment_id = environment_id.clone(); + move |request: RelayRequest| { + let udp_state = udp_state.clone(); + let udp_decider = udp_decider.clone(); + let environment_id = environment_id.clone(); + async move { + inspect_socks5_udp(request, udp_state, udp_decider, environment_id).await + } + } + })); let socks_acceptor = base.with_udp_associator(udp_relay); listener .serve(AddInputExtensionLayer::new(state).into_layer(socks_acceptor)) @@ -154,6 +178,7 @@ async fn handle_socks5_tcp( req: TcpRequest, tcp_connector: TargetCheckedTcpConnector, policy_decider: Option>, + environment_id: Option, ) -> Result, BoxError> { let app_state = req .extensions() @@ -268,6 +293,7 @@ async fn handle_socks5_tcp( protocol: NetworkProtocol::Socks5Tcp, host: host.clone(), port, + environment_id, client_addr: client.clone(), method: None, command: None, @@ -518,6 +544,7 @@ async fn inspect_socks5_udp( request: RelayRequest, state: Arc, policy_decider: Option>, + environment_id: Option, ) -> io::Result { let RelayRequest { server_address, @@ -624,6 +651,7 @@ async fn inspect_socks5_udp( protocol: NetworkProtocol::Socks5Udp, host: host.clone(), port, + environment_id, client_addr: client.clone(), method: None, command: None, @@ -781,6 +809,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state.clone()), /*policy_decider*/ None, + /*environment_id*/ None, ) .await }) @@ -824,6 +853,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect("limited-mode HTTPS should use MITM"); @@ -849,6 +879,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await }) @@ -894,6 +925,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect_err("limited-mode HTTPS requires MITM"); @@ -931,6 +963,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect("hooked HTTPS should use MITM"); @@ -965,6 +998,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect_err("hooked non-HTTPS SOCKS should require MITM"); @@ -990,7 +1024,10 @@ mod tests { }; let (result, events) = capture_events(|| async { - inspect_socks5_udp(request, state, /*policy_decider*/ None).await + inspect_socks5_udp( + request, state, /*policy_decider*/ None, /*environment_id*/ None, + ) + .await }) .await; assert!(result.is_err(), "limited-mode UDP request should be denied"); diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index 3437b0d32deb..5d36706ffb65 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 000000000000..85f5da9017b7 --- /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 8f0471088ffb..efff5c24e3e6 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -707,7 +707,6 @@ impl SessionTelemetry { duration: Duration, ) { let mut kind = None; - let mut error_message = None; let mut success = true; match result { @@ -724,49 +723,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 +751,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( @@ -1209,6 +1173,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(), diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 7daa78bc06cd..1a4eb23af1a5 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/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 767771d7c25b..ad83655463bc 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -17,6 +17,7 @@ const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, + pub plugin_namespace: Option, pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, @@ -83,7 +84,9 @@ pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option { plugins: Vec>, @@ -124,11 +127,15 @@ impl PluginLoadOutcome { let mut skill_roots = Vec::new(); let mut seen_paths = HashSet::new(); for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + let Some(plugin_namespace) = &plugin.plugin_namespace else { + continue; + }; for path in &plugin.skill_roots { if seen_paths.insert(path.clone()) { skill_roots.push(PluginSkillRoot { path: path.clone(), plugin_id: plugin.config_name.clone(), + plugin_namespace: plugin_namespace.clone(), plugin_root: plugin.root.clone(), }); } @@ -216,6 +223,12 @@ mod tests { LoadedPlugin { config_name: config_name.to_string(), manifest_name: None, + plugin_namespace: Some( + config_name + .split_once('@') + .map_or(config_name, |(name, _)| name) + .to_string(), + ), manifest_description: None, root: test_path(config_name), enabled: true, @@ -243,6 +256,7 @@ mod tests { vec![PluginSkillRoot { path: shared_root, plugin_id: "zeta@test".to_string(), + plugin_namespace: "zeta".to_string(), plugin_root: test_path("zeta@test"), }] ); diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index b1f04c4c12d4..599f4ee40720 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -17,12 +17,19 @@ pub struct PluginManifest { /// Component resources declared by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { - pub skills: Option, - pub mcp_servers: Option, + pub skills: Vec, + pub mcp_servers: Option>, pub apps: Option, pub hooks: Option>, } +/// MCP server declarations embedded in or referenced by a plugin manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginManifestMcpServers { + Path(Resource), + Object(String), +} + /// Hook declarations embedded in or referenced by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginManifestHooks { @@ -109,6 +116,15 @@ impl PluginManifest { Some(PluginManifestHooks::Inline(hooks)) => Some(PluginManifestHooks::Inline(hooks)), None => None, }; + let mcp_servers = match mcp_servers { + Some(PluginManifestMcpServers::Path(path)) => { + Some(PluginManifestMcpServers::Path(map(path)?)) + } + Some(PluginManifestMcpServers::Object(servers)) => { + Some(PluginManifestMcpServers::Object(servers)) + } + None => None, + }; let interface = match interface { Some(interface) => { let PluginManifestInterface { @@ -156,8 +172,11 @@ impl PluginManifest { description, keywords, paths: PluginManifestPaths { - skills: skills.map(&mut map).transpose()?, - mcp_servers: mcp_servers.map(&mut map).transpose()?, + skills: skills + .into_iter() + .map(&mut map) + .collect::, _>>()?, + mcp_servers, apps: apps.map(&mut map).transpose()?, hooks, }, diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 44993a640cd3..99bc3c20891f 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -4,6 +4,7 @@ use super::ResolvedPluginError; use crate::manifest::PluginManifest; use crate::manifest::PluginManifestHooks; use crate::manifest::PluginManifestInterface; +use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -36,8 +37,8 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: Some(skills.clone()), - mcp_servers: Some(mcp_servers.clone()), + skills: vec![skills.clone()], + mcp_servers: Some(PluginManifestMcpServers::Path(mcp_servers.clone())), apps: Some(apps.clone()), hooks: Some(PluginManifestHooks::Paths(vec![hooks.clone()])), }, @@ -70,8 +71,11 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: Some(resource("executor-1", skills)), - mcp_servers: Some(resource("executor-1", mcp_servers)), + skills: vec![resource("executor-1", skills)], + mcp_servers: Some(PluginManifestMcpServers::Path(resource( + "executor-1", + mcp_servers, + ))), apps: Some(resource("executor-1", apps)), hooks: Some(PluginManifestHooks::Paths(vec![resource( "executor-1", @@ -99,8 +103,8 @@ fn environment_descriptor_rejects_resources_outside_package_root() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: None, - mcp_servers: Some(outside.clone()), + skills: Vec::new(), + mcp_servers: Some(PluginManifestMcpServers::Path(outside.clone())), apps: None, hooks: None, }, diff --git a/codex-rs/prompts/src/agents.rs b/codex-rs/prompts/src/agents.rs deleted file mode 100644 index e716d5bf947e..000000000000 --- a/codex-rs/prompts/src/agents.rs +++ /dev/null @@ -1 +0,0 @@ -pub const HIERARCHICAL_AGENTS_MESSAGE: &str = include_str!("../templates/agents/hierarchical.md"); diff --git a/codex-rs/prompts/src/lib.rs b/codex-rs/prompts/src/lib.rs index 8830e445d4fb..5a30358393d8 100644 --- a/codex-rs/prompts/src/lib.rs +++ b/codex-rs/prompts/src/lib.rs @@ -1,4 +1,3 @@ -mod agents; mod apply_patch; mod compact; mod goals; @@ -7,7 +6,6 @@ mod realtime; mod review_exit; mod review_request; -pub use agents::HIERARCHICAL_AGENTS_MESSAGE; pub use apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; pub use compact::SUMMARIZATION_PROMPT; pub use compact::SUMMARY_PREFIX; diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index f360a987dbb1..a4a043f24fae 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -22,6 +22,8 @@ const APPROVAL_POLICY_ON_FAILURE: &str = include_str!("../templates/permissions/approval_policy/on_failure.md"); const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("../templates/permissions/approval_policy/on_request.md"); +const APPROVAL_POLICY_ON_REQUEST_AUTO_REVIEW: &str = + include_str!("../templates/permissions/approval_policy/on_request_auto_review.md"); const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = include_str!("../templates/permissions/approval_policy/on_request_rule_request_permission.md"); const AUTO_REVIEW_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; @@ -208,6 +210,8 @@ fn approval_text( let on_request_instructions = || { let on_request_rule = if exec_permission_approvals_enabled { APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION.to_string() + } else if approvals_reviewer == ApprovalsReviewer::AutoReview { + APPROVAL_POLICY_ON_REQUEST_AUTO_REVIEW.to_string() } else { APPROVAL_POLICY_ON_REQUEST_RULE.to_string() }; diff --git a/codex-rs/prompts/src/permissions_instructions_tests.rs b/codex-rs/prompts/src/permissions_instructions_tests.rs index 580e9781ce7a..8564a7cf1ecf 100644 --- a/codex-rs/prompts/src/permissions_instructions_tests.rs +++ b/codex-rs/prompts/src/permissions_instructions_tests.rs @@ -257,17 +257,98 @@ fn on_request_includes_tool_guidance_alongside_inline_permission_guidance_when_b #[test] fn auto_review_approvals_append_auto_review_specific_guidance() { + let mut exec_policy = Policy::empty(); + exec_policy + .add_prefix_rule(&["git".to_string(), "pull".to_string()], Decision::Allow) + .expect("add rule"); let text = approval_text( AskForApproval::OnRequest, ApprovalsReviewer::AutoReview, - &Policy::empty(), + &exec_policy, /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, ); assert!(text.contains("`approvals_reviewer` is `auto_review`")); + assert!(text.contains("materially safer alternative")); assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); + assert!(text.contains( + "When the sandbox is likely to block a command needed for the task, request escalation up front" + )); + assert!(text.contains( + "When unsure, prefer requesting escalation unnecessarily over failing to request it when needed" + )); + assert!( + text.contains( + "Request escalation for commands that need write access outside writable roots" + ) + ); + assert!(text.contains( + "Request escalation for git operations that may write lock files, such as updating the index or refs" + )); + assert!(!text.contains("Request escalation for GUI commands")); + assert!(!text.contains("Use escalation when it is the direct or most reliable way")); + assert!(!text.contains("Do not spend extra turns running likely-to-fail sandbox probes first")); + assert!(text.contains("Do not include a `prefix_rule` parameter.")); + assert!(text.contains( + "Request escalation for commands that may need network access, including HTTP calls, package registries, internal services, data-service APIs, remote queries, data fetches, or live probes" + )); + assert!(text.contains( + "Request escalation for commands that may need remote authentication, cluster, cloud, or database access" + )); + assert!(text.contains( + "Request escalation for commands that may need process, cache, or other environment access outside the sandbox" + )); + assert!( + text.contains( + "including DNS, connection, authentication, retry, or service endpoint errors" + ) + ); + assert!(text.contains( + "Request escalation before potentially destructive actions, such as `rm` or `git reset`" + )); + assert!(text.contains( + "If a command may be hanging on sandbox-blocked access, stop after a short timeout and rerun with `require_escalated`" + )); + assert!(!text.contains("Be judicious with escalating")); + assert!(text.contains("Approved command prefixes")); + assert!(text.contains(r#"["git", "pull"]"#)); + assert!(!text.contains("prefix_rule guidance")); +} + +#[test] +fn normal_on_request_keeps_user_approval_wording() { + let text = approval_text( + AskForApproval::OnRequest, + ApprovalsReviewer::User, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ false, + /*request_permissions_tool_enabled*/ false, + ); + + assert!(text.contains("approved by the user")); + assert!(text.contains("Be judicious with escalating")); + assert!(!text.contains( + "When the sandbox is likely to block a command needed for the task, request escalation up front" + )); +} + +#[test] +fn auto_review_on_request_with_inline_permission_requests_keeps_suffix() { + let text = approval_text( + AskForApproval::OnRequest, + ApprovalsReviewer::AutoReview, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ true, + /*request_permissions_tool_enabled*/ false, + ); + + assert!(text.contains("with_additional_permissions")); + assert!(text.contains("`approvals_reviewer` is `auto_review`")); assert!(text.contains("materially safer alternative")); + assert!(!text.contains( + "When the sandbox is likely to block a command needed for the task, request escalation up front" + )); } #[test] diff --git a/codex-rs/prompts/templates/agents/hierarchical.md b/codex-rs/prompts/templates/agents/hierarchical.md deleted file mode 100644 index 4f782078c8e3..000000000000 --- a/codex-rs/prompts/templates/agents/hierarchical.md +++ /dev/null @@ -1,7 +0,0 @@ -Files called AGENTS.md commonly appear in many places inside a container - at "/", in "~", deep within git repositories, or in any other directory; their location is not limited to version-controlled folders. - -Their purpose is to pass along human guidance to you, the agent. Such guidance can include coding standards, explanations of the project layout, steps for building or testing, and even wording that must accompany a GitHub pull-request description produced by the agent; all of it is to be followed. - -Each AGENTS.md governs the entire directory that contains it and every child directory beneath that point. Whenever you change a file, you have to comply with every AGENTS.md whose scope covers that file. Naming conventions, stylistic rules and similar directives are restricted to the code that falls inside that scope unless the document explicitly states otherwise. - -When two AGENTS.md files disagree, the one located deeper in the directory structure overrides the higher-level file, while instructions given directly in the prompt by the system, developer, or user outrank any AGENTS.md content. diff --git a/codex-rs/prompts/templates/permissions/approval_policy/on_request_auto_review.md b/codex-rs/prompts/templates/permissions/approval_policy/on_request_auto_review.md new file mode 100644 index 000000000000..82bb24e96346 --- /dev/null +++ b/codex-rs/prompts/templates/permissions/approval_policy/on_request_auto_review.md @@ -0,0 +1,45 @@ +# Escalation Requests + +Commands are run outside the sandbox after approval. The command string is split into independent command segments at shell control operators, including but not limited to: + +- Pipes: | +- Logical operators: &&, || +- Command separators: ; +- Subshell boundaries: (...), $() + +Each resulting segment is evaluated independently for sandbox restrictions and approval requirements. + +Example: + +git pull | tee output.txt + +This is treated as two command segments: + +["git", "pull"] + +["tee", "output.txt"] + +Commands that use more advanced shell features like redirection (>, >>, <), substitutions ($(...), ...), environment variables (FOO=bar), or wildcard patterns (*, ?) require care because each independent command segment is evaluated separately. + +## How to request escalation + +IMPORTANT: To request approval to execute a command that will require escalated privileges: + +- Provide the `sandbox_permissions` parameter with the value `"require_escalated"` +- Include a concise `justification` parameter that explains why escalated privileges are needed. +- Do not include a `prefix_rule` parameter. + +## When to request escalation + +While commands are running inside the sandbox, here are some scenarios that justify escalation: + +- When the sandbox is likely to block a command needed for the task, request escalation up front. +- Request escalation for commands that need write access outside writable roots, such as tests that write to `/var`. +- Request escalation for git operations that may write lock files, such as updating the index or refs. +- Request escalation for commands that may need network access, including HTTP calls, package registries, internal services, data-service APIs, remote queries, data fetches, or live probes. +- Request escalation for commands that may need remote authentication, cluster, cloud, or database access. +- Request escalation for commands that may need process, cache, or other environment access outside the sandbox. +- If a sandboxed attempt fails with sandboxing or likely network symptoms, including DNS, connection, authentication, retry, or service endpoint errors, rerun with `sandbox_permissions` set to `"require_escalated"` and include `justification`. +- If a command may be hanging on sandbox-blocked access, stop after a short timeout and rerun with `require_escalated`. +- Request escalation before potentially destructive actions, such as `rm` or `git reset`, that the user did not explicitly ask for. +- When unsure, prefer requesting escalation unnecessarily over failing to request it when needed. diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index e6c088661f65..07af0dd699f8 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, }, ChatgptPool { @@ -43,7 +43,17 @@ pub enum ProviderAccount { active_account_id: Option, members: Vec, }, - AmazonBedrock, + AmazonBedrock { + credential_source: AmazonBedrockCredentialSource, + }, +} + +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub enum AmazonBedrockCredentialSource { + CodexManaged, + AwsManaged, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index ace096359c2b..5cf4f27f183c 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -229,6 +229,16 @@ pub struct ExecApprovalRequestEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + /// Environment in which the command will run. + #[serde( + default, + rename = "environmentId", + alias = "environment_id", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + #[ts(rename = "environmentId")] + pub environment_id: Option, #[ts(type = "number")] pub started_at_ms: i64, /// The command to be executed. @@ -332,6 +342,15 @@ pub enum ElicitationRequest { message: String, requested_schema: JsonValue, }, + #[serde(rename = "openai/form")] + #[ts(rename = "openai/form")] + OpenAiForm { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "_meta")] + meta: Option, + message: String, + requested_schema: JsonValue, + }, Url { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] #[ts(optional, rename = "_meta")] @@ -345,7 +364,9 @@ pub enum ElicitationRequest { impl ElicitationRequest { pub fn message(&self) -> &str { match self { - Self::Form { message, .. } | Self::Url { message, .. } => message, + Self::Form { message, .. } + | Self::OpenAiForm { message, .. } + | Self::Url { message, .. } => message, } } } diff --git a/codex-rs/protocol/src/compacted_item.rs b/codex-rs/protocol/src/compacted_item.rs new file mode 100644 index 000000000000..0f437e0bb6c8 --- /dev/null +++ b/codex-rs/protocol/src/compacted_item.rs @@ -0,0 +1,106 @@ +use crate::models::ResponseItem; +use crate::protocol::CompactedItem; +use serde::Deserialize; + +// Before `window_number` was introduced, the numeric window number was serialized as +// `window_id`. Accept that shape so existing rollouts remain resumable. +impl<'de> Deserialize<'de> for CompactedItem { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let serialized = SerializedCompactedItem::deserialize(deserializer)?; + let mut window_number = serialized.window_number; + let window_id = match serialized.window_id { + Some(SerializedWindowId::Id(window_id)) => Some(window_id), + Some(SerializedWindowId::LegacyWindowNumber(legacy_window_number)) => { + window_number.get_or_insert(legacy_window_number); + None + } + None => None, + }; + Ok(Self { + 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, + }) + } +} + +#[derive(Deserialize)] +struct SerializedCompactedItem { + message: String, + #[serde(default)] + replacement_history: Option>, + #[serde(default)] + window_number: Option, + #[serde(default)] + first_window_id: Option, + #[serde(default)] + previous_window_id: Option, + #[serde(default)] + window_id: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum SerializedWindowId { + Id(String), + LegacyWindowNumber(u64), +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn serializes_window_number_and_id() -> Result<()> { + let item = CompactedItem { + 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()), + }; + + assert_eq!( + serde_json::to_value(item)?, + 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", + }) + ); + Ok(()) + } + + #[test] + fn migrates_legacy_numeric_window_id() -> Result<()> { + let item = serde_json::from_value::(json!({ + "message": "summary", + "window_id": 3, + }))?; + + assert_eq!( + item, + CompactedItem { + message: "summary".to_string(), + replacement_history: None, + window_number: Some(3), + first_window_id: None, + previous_window_id: None, + window_id: None, + } + ); + Ok(()) + } +} diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 59f38f0f1c34..83ef4df362bd 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -296,15 +296,33 @@ pub enum Personality { Pragmatic, } +/// 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, )] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum MultiAgentMode { + None, + #[default] + ExplicitRequestOnly, + Proactive, +} + +#[derive( + Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum WebSearchMode { Disabled, #[default] Cached, + Indexed, Live, } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 01f44576ace3..76bc0e399255 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -188,9 +188,15 @@ pub struct McpToolCallItem { pub arguments: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, pub status: McpToolCallStatus, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -398,7 +404,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option Option { + let mut text_parts = Vec::with_capacity(content.len()); + for part in content { + match part { + AgentMessageInputContent::InputText { text } => text_parts.push(text.as_str()), + AgentMessageInputContent::EncryptedContent { .. } => return None, + } + } + + let text = text_parts.join("\n"); + (!text.trim().is_empty()).then_some(text) +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { @@ -890,8 +904,12 @@ pub enum MessagePhase { FinalAnswer, } +/// 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. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] -pub struct ResponseItemMetadata { +pub struct InternalChatMessageMetadataPassthrough { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub turn_id: Option, @@ -901,8 +919,8 @@ pub struct ResponseItemMetadata { #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { - #[serde(default, skip_serializing)] - #[ts(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, role: String, content: Vec, @@ -914,21 +932,23 @@ 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")] + #[ts(optional)] + id: Option, author: String, recipient: String, 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)] - #[ts(skip)] - #[schemars(skip)] - id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, summary: Vec, #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")] #[ts(optional)] @@ -936,12 +956,12 @@ 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. - #[serde(default, skip_serializing)] - #[ts(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, /// Set when using the Responses API. call_id: Option, @@ -949,11 +969,11 @@ 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)] - #[ts(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -966,11 +986,11 @@ 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)] - #[ts(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -981,7 +1001,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: @@ -989,17 +1009,20 @@ pub enum ResponseItem { // - an array of structured content items (`content_items`) // We keep this behavior centralized in `FunctionCallOutputPayload`. FunctionCallOutput { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, call_id: String, #[ts(as = "FunctionCallOutputBody")] #[schemars(with = "FunctionCallOutputBody")] 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)] - #[ts(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1010,12 +1033,15 @@ 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 // text or structured content items. CustomToolCallOutput { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1025,9 +1051,12 @@ 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")] + #[ts(optional)] + id: Option, call_id: Option, status: String, execution: String, @@ -1035,7 +1064,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`): @@ -1046,8 +1075,8 @@ pub enum ResponseItem { // "action": {"type":"search","query":"weather: San Francisco, CA"} // } WebSearchCall { - #[serde(default, skip_serializing)] - #[ts(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1057,7 +1086,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: @@ -1069,7 +1098,9 @@ pub enum ResponseItem { // "result":"..." // } ImageGenerationCall { - id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, status: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1077,27 +1108,35 @@ 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 { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: 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, }, ContextCompaction { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, #[serde(other)] Other, @@ -1109,9 +1148,51 @@ impl ResponseItem { matches!(self, Self::Message { role, .. } if role == "user") } + /// Returns the non-empty Responses API item ID, if present. + pub fn id(&self) -> Option<&str> { + match self { + Self::Message { id, .. } + | Self::AgentMessage { id, .. } + | Self::LocalShellCall { id, .. } + | Self::FunctionCall { id, .. } + | Self::ToolSearchCall { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::CustomToolCall { id, .. } + | Self::CustomToolCallOutput { id, .. } + | Self::ToolSearchOutput { id, .. } + | Self::WebSearchCall { id, .. } + | Self::Reasoning { id, .. } + | Self::ImageGenerationCall { id, .. } + | Self::Compaction { id, .. } + | Self::ContextCompaction { id, .. } => id.as_deref().filter(|id| !id.is_empty()), + Self::CompactionTrigger { .. } | Self::Other => None, + } + } + + /// 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::AgentMessage { id, .. } + | Self::LocalShellCall { id, .. } + | Self::FunctionCall { id, .. } + | Self::ToolSearchCall { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::CustomToolCall { id, .. } + | Self::CustomToolCallOutput { id, .. } + | Self::ToolSearchOutput { id, .. } + | Self::WebSearchCall { id, .. } + | Self::Reasoning { id, .. } + | Self::ImageGenerationCall { id, .. } + | Self::Compaction { id, .. } + | Self::ContextCompaction { id, .. } => *id = new_id, + Self::CompactionTrigger { .. } | Self::Other => {} + } + } + /// 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()) } @@ -1121,59 +1202,154 @@ impl ResponseItem { if turn_id.is_empty() || self.turn_id().is_some() { return; } - let Some(metadata) = self.metadata_mut() else { + let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else { return; }; metadata - .get_or_insert_with(ResponseItemMetadata::default) + .get_or_insert_with(InternalChatMessageMetadataPassthrough::default) .turn_id = Some(turn_id.to_string()); } - /// 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::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::CompactionTrigger { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => metadata.as_ref(), 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::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::CompactionTrigger { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => Some(metadata), Self::Other => None, } } @@ -1415,19 +1591,21 @@ 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(); Self::FunctionCallOutput { + id: None, call_id, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } ResponseInputItem::CustomToolCallOutput { @@ -1435,10 +1613,11 @@ impl From for ResponseItem { name, output, } => Self::CustomToolCallOutput { + id: None, call_id, name, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::ToolSearchOutput { call_id, @@ -1450,7 +1629,8 @@ impl From for ResponseItem { status, execution, tools, - metadata: None, + id: None, + internal_chat_message_metadata_passthrough: None, }, } } @@ -1970,6 +2150,20 @@ mod tests { 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; + #[test] + fn plaintext_agent_message_content_rejects_mixed_encrypted_content() { + let content = vec![ + AgentMessageInputContent::InputText { + text: "Message Type: MESSAGE\nPayload:\n".to_string(), + }, + AgentMessageInputContent::EncryptedContent { + encrypted_content: "encrypted-payload".to_string(), + }, + ]; + + assert_eq!(plaintext_agent_message_content(&content), None); + } + #[test] fn response_input_message_conversion_preserves_phase() { let item = ResponseItem::from(ResponseInputItem::Message { @@ -1989,14 +2183,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); @@ -2004,7 +2199,7 @@ mod tests { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}], - "metadata": { + "internal_chat_message_metadata_passthrough": { "turn_id": "turn-1", "other": "ignored", }, @@ -2014,11 +2209,14 @@ mod tests { item.stamp_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(""))); + let mut empty_turn_id = + response_item_with_passthrough_metadata(Some(passthrough_metadata(""))); empty_turn_id.stamp_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); + let mut missing_turn_id = response_item_with_passthrough_metadata( + /*internal_chat_message_metadata_passthrough*/ None, + ); missing_turn_id.stamp_turn_id_if_missing(""); missing_turn_id.stamp_turn_id_if_missing("turn-1"); assert_eq!(missing_turn_id.turn_id(), Some("turn-1")); @@ -2029,7 +2227,25 @@ mod tests { Ok(()) } - fn response_item_with_metadata(metadata: Option) -> ResponseItem { + #[test] + fn response_item_id_getter_and_setter() { + 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())); + + assert_eq!(item.id(), Some("msg_test")); + + item.set_id(/*new_id*/ None); + + assert_eq!(item.id(), None); + } + + fn response_item_with_passthrough_metadata( + internal_chat_message_metadata_passthrough: Option, + ) -> ResponseItem { ResponseItem::Message { id: None, role: "user".to_string(), @@ -2037,12 +2253,12 @@ 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()), } } @@ -2144,11 +2360,11 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), 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, } ); } @@ -2166,11 +2382,11 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2522,7 +2738,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, } ); } @@ -2868,8 +3084,9 @@ mod tests { assert_eq!( item, ResponseItem::Compaction { + id: None, encrypted_content: "abc".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -2884,8 +3101,9 @@ mod tests { assert_eq!( item, ResponseItem::ContextCompaction { + id: None, encrypted_content: Some("abc".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -2893,7 +3111,9 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { metadata: None }; + let item = ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }; assert_eq!( serde_json::to_value(item)?, @@ -2905,15 +3125,17 @@ mod tests { } #[test] - fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { metadata: None }; + fn serializes_stamped_compaction_trigger_passthrough_metadata() -> Result<()> { + let mut item = ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }; item.stamp_turn_id_if_missing("turn-1"); assert_eq!( serde_json::to_value(item)?, serde_json::json!({ "type": "compaction_trigger", - "metadata": { + "internal_chat_message_metadata_passthrough": { "turn_id": "turn-1", }, }) @@ -2927,7 +3149,12 @@ mod tests { let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); + assert_eq!( + item, + ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + } + ); Ok(()) } @@ -2968,7 +3195,6 @@ mod tests { queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]), }), Some("completed".into()), - true, ), ( r#"{ @@ -2984,7 +3210,6 @@ mod tests { url: Some("https://example.com".into()), }), Some("open".into()), - true, ), ( r#"{ @@ -3002,7 +3227,6 @@ mod tests { pattern: Some("installation".into()), }), Some("in_progress".into()), - true, ), ( r#"{ @@ -3013,26 +3237,21 @@ mod tests { Some("ws_partial".into()), None, Some("in_progress".into()), - false, ), ]; - for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) in cases - { + for (json_literal, expected_id, expected_action, expected_status) in cases { let parsed: ResponseItem = serde_json::from_str(json_literal)?; let expected = ResponseItem::WebSearchCall { id: expected_id.clone(), status: expected_status.clone(), action: expected_action.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(parsed, expected); let serialized = serde_json::to_value(&parsed)?; - let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?; - if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() { - obj.remove("id"); - } + let expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?; assert_eq!(serialized, expected_serialized); } @@ -3112,7 +3331,7 @@ mod tests { "query": "calendar create", "limit": 1, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3156,6 +3375,7 @@ mod tests { assert_eq!( ResponseItem::from(input.clone()), ResponseItem::ToolSearchOutput { + id: None, call_id: Some("search-1".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -3173,7 +3393,7 @@ mod tests { "additionalProperties": false, } })], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3227,7 +3447,7 @@ mod tests { arguments: serde_json::json!({ "paths": ["crm"], }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3243,11 +3463,12 @@ mod tests { assert_eq!( parsed_output, ResponseItem::ToolSearchOutput { + id: None, call_id: None, 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 d4f299fe6641..b3e39dac97d7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -21,6 +21,7 @@ use crate::approvals::ElicitationRequestEvent; use crate::config_types::ApprovalsReviewer; use crate::config_types::CollaborationMode; use crate::config_types::ModeKind; +use crate::config_types::MultiAgentMode; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::config_types::WindowsSandboxLevel; @@ -41,7 +42,6 @@ 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; @@ -56,7 +56,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; @@ -104,6 +106,8 @@ pub const PLUGINS_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const PLUGINS_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const COLLABORATION_MODE_OPEN_TAG: &str = ""; pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; +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 USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; @@ -180,12 +184,16 @@ pub struct McpServerRefreshConfig { #[derive(Debug, Clone, PartialEq)] pub struct ConversationStartParams { - /// Overrides the configured realtime architecture for this session only. - pub architecture: Option, + /// Whether Codex response handoffs are managed through explicit client append calls. + pub client_managed_handoffs: bool, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. pub codex_responses_as_items: bool, /// Optional prefix added to automatic Codex response items when `codex_responses_as_items` is set. pub codex_response_item_prefix: Option, + /// Optional prefix added to automatic V1 Codex commentary sent with + /// `conversation.handoff.append` when `codex_responses_as_items` is not set. Final answers are + /// sent without the prefix. + pub codex_response_handoff_prefix: Option, /// Overrides the configured realtime model for this session only. pub model: Option, /// Selects whether the realtime session should produce text or audio output. @@ -411,6 +419,7 @@ pub enum ConversationTextRole { #[default] User, Developer, + Assistant, } #[derive(Debug, Clone, PartialEq)] @@ -474,6 +483,9 @@ pub struct ThreadSettingsOverrides { /// Takes precedence over model, effort, and developer instructions if set. pub collaboration_mode: Option, + /// Updated multi-agent mode for this turn and subsequent turns. + pub multi_agent_mode: Option, + /// Updated personality preference. pub personality: Option, } @@ -693,9 +705,6 @@ pub struct InterAgentCommunication { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub encrypted_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub metadata: Option, pub trigger_turn: bool, } @@ -713,7 +722,6 @@ impl InterAgentCommunication { other_recipients, content, encrypted_content: None, - metadata: None, trigger_turn, } } @@ -731,7 +739,6 @@ impl InterAgentCommunication { other_recipients, content: String::new(), encrypted_content: Some(encrypted_content), - metadata: None, trigger_turn, } } @@ -748,18 +755,34 @@ impl InterAgentCommunication { pub fn to_model_input_item(&self) -> ResponseItem { let content = match &self.encrypted_content { - Some(encrypted_content) => AgentMessageInputContent::EncryptedContent { - encrypted_content: encrypted_content.clone(), - }, - None => AgentMessageInputContent::InputText { + Some(encrypted_content) => { + let message_type = if self.trigger_turn { + "NEW_TASK" + } else { + "MESSAGE" + }; + vec![ + AgentMessageInputContent::InputText { + text: format!( + "Message Type: {message_type}\nTask name: {}\nSender: {}\nPayload:\n", + self.recipient, self.author + ), + }, + AgentMessageInputContent::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }, + ] + } + None => vec![AgentMessageInputContent::InputText { text: self.content.clone(), - }, + }], }; ResponseItem::AgentMessage { + id: None, author: self.author.to_string(), recipient: self.recipient.to_string(), - content: vec![content], - metadata: self.metadata.clone(), + content, + internal_chat_message_metadata_passthrough: None, } } @@ -1234,6 +1257,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), @@ -1536,15 +1562,6 @@ pub enum RealtimeConversationVersion { V2, } -#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum RealtimeConversationArchitecture { - #[default] - #[serde(rename = "realtimeapi")] - RealtimeApi, - Avas, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct RealtimeConversationStartedEvent { pub realtime_session_id: Option, @@ -1922,6 +1939,13 @@ 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, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; @@ -1985,6 +2009,8 @@ pub struct ThreadSettingsSnapshot { #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, pub collaboration_mode: CollaborationMode, + #[serde(default)] + pub multi_agent_mode: MultiAgentMode, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] @@ -2312,9 +2338,15 @@ pub struct McpToolCallBeginEvent { pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, } @@ -2325,9 +2357,15 @@ pub struct McpToolCallEndEvent { pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, #[ts(type = "string")] pub duration: Duration, @@ -2535,6 +2573,26 @@ impl InitialHistory { } } + pub fn get_latest_effective_multi_agent_mode(&self) -> Option { + let items = match self { + InitialHistory::New | InitialHistory::Cleared => return None, + InitialHistory::Resumed(resumed) => &resumed.history, + InitialHistory::Forked(items) => items, + }; + items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::TurnContext(turn_context) => Some(turn_context), + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + }) + .and_then(|turn_context| turn_context.multi_agent_mode) + } + pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { let meta = self.get_resumed_session_meta()?; Some((meta.source.clone(), meta.thread_source.clone())) @@ -2863,6 +2921,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, @@ -2905,8 +2964,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(), @@ -2927,7 +2988,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, @@ -2935,6 +2996,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 { @@ -2947,13 +3037,23 @@ pub enum RolloutItem { EventMsg(EventMsg), } -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +#[derive(Serialize, Clone, Debug, PartialEq, JsonSchema, TS)] pub struct CompactedItem { pub message: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub replacement_history: Option>, + /// 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 window_id: Option, + pub previous_window_id: Option, + /// UUIDv7 identity of this context window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_id: Option, } impl From for ResponseItem { @@ -2965,7 +3065,7 @@ impl From for ResponseItem { text: value.message, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } } @@ -2980,11 +3080,11 @@ pub struct TurnContextNetworkItem { /// context updates, and again after mid-turn compaction when replacement /// history re-establishes full context, so resume/fork replay can recover the /// latest durable baseline. -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, - pub cwd: PathBuf, + pub cwd: AbsolutePathBuf, /// Effective workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions in `permission_profile`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3010,6 +3110,9 @@ pub struct TurnContextItem { pub collaboration_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_version: Option, + /// Effective model-visible mode used as the durable context-diff baseline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multi_agent_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub realtime_active: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -3028,7 +3131,7 @@ impl TurnContextItem { self.file_system_sandbox_policy.clone().unwrap_or_else(|| { FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &self.sandbox_policy, - &self.cwd, + self.cwd.as_path(), ) }); PermissionProfile::from_runtime_permissions_with_enforcement( @@ -3234,7 +3337,7 @@ pub struct ExecCommandBeginEvent { /// The command to be executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] @@ -3260,7 +3363,7 @@ pub struct ExecCommandEndEvent { /// The command that was executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] @@ -4251,7 +4354,6 @@ mod tests { other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), encrypted_content: None, - metadata: None, trigger_turn: true, }; @@ -4267,6 +4369,36 @@ mod tests { ); } + #[test] + fn queued_encrypted_inter_agent_communication_renders_message_envelope() { + let communication = InterAgentCommunication::new_encrypted( + AgentPath::root().join("worker").expect("author path"), + AgentPath::root(), + Vec::new(), + "encrypted payload".to_string(), + /*trigger_turn*/ false, + ); + + assert_eq!( + communication.to_model_input_item(), + ResponseItem::AgentMessage { + id: None, + author: "/root/worker".to_string(), + recipient: "/root".to_string(), + content: vec![ + AgentMessageInputContent::InputText { + text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/worker\nPayload:\n" + .to_string(), + }, + AgentMessageInputContent::EncryptedContent { + encrypted_content: "encrypted payload".to_string(), + }, + ], + internal_chat_message_metadata_passthrough: None, + } + ); + } + #[test] fn session_source_from_startup_arg_normalizes_custom_values() { assert_eq!( @@ -4898,7 +5030,9 @@ mod tests { server: "server".into(), tool: "tool".into(), arguments: json!({"arg": "value"}), + connector_id: Some("connector".into()), mcp_app_resource_uri: Some("app://connector".into()), + link_id: Some("link_123".into()), plugin_id: Some("sample@test".into()), status: McpToolCallStatus::InProgress, result: None, @@ -4914,10 +5048,12 @@ mod tests { assert_eq!(event.call_id, "mcp-1"); assert_eq!(event.invocation.server, "server"); assert_eq!(event.invocation.tool, "tool"); + assert_eq!(event.connector_id.as_deref(), Some("connector")); assert_eq!( event.mcp_app_resource_uri.as_deref(), Some("app://connector") ); + assert_eq!(event.link_id.as_deref(), Some("link_123")); assert_eq!(event.plugin_id.as_deref(), Some("sample@test")); } _ => panic!("expected McpToolCallBegin event"), @@ -5005,7 +5141,9 @@ mod tests { server: "server".into(), tool: "tool".into(), arguments: json!({"arg": "value"}), + connector_id: Some("connector".into()), mcp_app_resource_uri: Some("app://connector".into()), + link_id: Some("link_123".into()), plugin_id: Some("sample@test".into()), status: McpToolCallStatus::Completed, result: Some(CallToolResult { @@ -5026,10 +5164,12 @@ mod tests { assert_eq!(event.call_id, "mcp-1"); assert_eq!(event.invocation.server, "server"); assert_eq!(event.invocation.tool, "tool"); + assert_eq!(event.connector_id.as_deref(), Some("connector")); assert_eq!( event.mcp_app_resource_uri.as_deref(), Some("app://connector") ); + assert_eq!(event.link_id.as_deref(), Some("link_123")); assert_eq!(event.plugin_id.as_deref(), Some("sample@test")); assert_eq!(event.duration, Duration::from_millis(42)); assert!(event.is_success()); @@ -5288,6 +5428,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() @@ -5296,6 +5437,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() @@ -5316,11 +5458,36 @@ mod tests { Ok(()) } + #[test] + fn latest_effective_multi_agent_mode_uses_latest_turn_context_even_when_unset() -> Result<()> { + let turn_context_item = |multi_agent_mode| -> Result { + let mut value = json!({ + "cwd": test_path_buf("/tmp"), + "approval_policy": "never", + "sandbox_policy": { "type": "danger-full-access" }, + "model": "gpt-5", + "summary": "auto", + }); + value["multi_agent_mode"] = serde_json::to_value(multi_agent_mode)?; + Ok(RolloutItem::TurnContext(serde_json::from_value(value)?)) + }; + + assert_eq!( + InitialHistory::Forked(vec![ + turn_context_item(Some(MultiAgentMode::Proactive))?, + turn_context_item(/*multi_agent_mode*/ None)?, + ]) + .get_latest_effective_multi_agent_mode(), + None + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { turn_id: None, - cwd: test_path_buf("/tmp"), + cwd: test_path_buf("/tmp").abs(), workspace_roots: None, current_date: None, timezone: None, @@ -5344,6 +5511,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: ReasoningSummaryConfig::Auto, diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs index cdb0a69bba15..b2bd834d59d6 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -1,15 +1,14 @@ use std::collections::HashMap; use std::time::Duration; -use anyhow::Error; use anyhow::Result; use codex_protocol::protocol::McpAuthStatus; +use futures::FutureExt; use reqwest::Client; -use reqwest::StatusCode; -use reqwest::Url; use reqwest::header::AUTHORIZATION; use reqwest::header::HeaderMap; -use serde::Deserialize; +use rmcp::transport::AuthorizationManager; +use rmcp::transport::auth::AuthError; use tracing::debug; use crate::oauth::StoredOAuthTokenStatus; @@ -20,8 +19,6 @@ use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); -const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version"; -const OAUTH_DISCOVERY_VERSION: &str = "2024-11-05"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct StreamableHttpOAuthDiscovery { @@ -89,65 +86,19 @@ async fn discover_streamable_http_oauth_with_headers( url: &str, default_headers: &HeaderMap, ) -> Result> { - let base_url = Url::parse(url)?; - // Use no_proxy to avoid a bug in the system-configuration crate that // can result in a panic. See #8912. let builder = Client::builder().timeout(DISCOVERY_TIMEOUT).no_proxy(); let client = apply_default_headers(builder, default_headers).build()?; - - let mut last_error: Option = None; - for candidate_path in discovery_paths(base_url.path()) { - let mut discovery_url = base_url.clone(); - discovery_url.set_path(&candidate_path); - - let response = match client - .get(discovery_url.clone()) - .header(OAUTH_DISCOVERY_HEADER, OAUTH_DISCOVERY_VERSION) - .send() - .await - { - Ok(response) => response, - Err(err) => { - last_error = Some(err.into()); - continue; - } - }; - - if response.status() != StatusCode::OK { - continue; - } - - let metadata = match response.json::().await { - Ok(metadata) => metadata, - Err(err) => { - last_error = Some(err.into()); - continue; - } - }; - - if metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() { - return Ok(Some(StreamableHttpOAuthDiscovery { - scopes_supported: normalize_scopes(metadata.scopes_supported), - })); - } + let mut authorization_manager = AuthorizationManager::new(url).await?; + authorization_manager.with_client(client)?; + match authorization_manager.discover_metadata().boxed().await { + Ok(metadata) => Ok(Some(StreamableHttpOAuthDiscovery { + scopes_supported: normalize_scopes(metadata.scopes_supported), + })), + Err(AuthError::NoAuthorizationSupport) => Ok(None), + Err(err) => Err(err.into()), } - - if let Some(err) = last_error { - debug!("OAuth discovery requests failed for {url}: {err:?}"); - } - - Ok(None) -} - -#[derive(Debug, Deserialize)] -struct OAuthDiscoveryMetadata { - #[serde(default)] - authorization_endpoint: Option, - #[serde(default)] - token_endpoint: Option, - #[serde(default)] - scopes_supported: Option>, } fn normalize_scopes(scopes_supported: Option>) -> Option> { @@ -172,37 +123,13 @@ fn normalize_scopes(scopes_supported: Option>) -> Option } } -/// Implements RFC 8414 section 3.1 for discovering well-known oauth endpoints. -/// This is a requirement for MCP servers to support OAuth. -/// https://datatracker.ietf.org/doc/html/rfc8414#section-3.1 -/// https://github.com/modelcontextprotocol/rust-sdk/blob/main/crates/rmcp/src/transport/auth.rs#L182 -fn discovery_paths(base_path: &str) -> Vec { - let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); - let canonical = "/.well-known/oauth-authorization-server".to_string(); - - if trimmed.is_empty() { - return vec![canonical]; - } - - let mut candidates = Vec::new(); - let mut push_unique = |candidate: String| { - if !candidates.contains(&candidate) { - candidates.push(candidate); - } - }; - - push_unique(format!("{canonical}/{trimmed}")); - push_unique(format!("/{trimmed}/.well-known/oauth-authorization-server")); - push_unique(canonical); - - candidates -} - #[cfg(test)] mod tests { use super::*; use axum::Json; use axum::Router; + use axum::http::StatusCode; + use axum::http::header::WWW_AUTHENTICATE; use axum::routing::get; use pretty_assertions::assert_eq; use serial_test::serial; @@ -344,6 +271,65 @@ mod tests { ); } + #[tokio::test] + async fn discover_streamable_http_oauth_follows_protected_resource_metadata() { + let authorization_server = spawn_oauth_discovery_server(serde_json::json!({ + "authorization_endpoint": "https://example.com/authorize", + "token_endpoint": "https://example.com/token", + "scopes_supported": ["read", " write ", "read"], + })) + .await; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let resource_metadata_url = format!("http://{address}/oauth-resource"); + let challenge = format!("Bearer resource_metadata=\"{resource_metadata_url}\""); + let authorization_server_url = authorization_server.url.clone(); + let app = Router::new() + .route( + "/mcp", + get(move || { + let challenge = challenge.clone(); + async move { (StatusCode::UNAUTHORIZED, [(WWW_AUTHENTICATE, challenge)]) } + }), + ) + .route( + "/oauth-resource", + get(move || { + let authorization_server_url = authorization_server_url.clone(); + async move { + Json(serde_json::json!({ + "resource": format!("http://{address}/mcp"), + "authorization_servers": [authorization_server_url], + })) + } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("server should run"); + }); + let resource_server = TestServer { + url: format!("http://{address}/mcp"), + handle, + }; + + let discovery = discover_streamable_http_oauth( + &resource_server.url, + /*http_headers*/ None, + /*env_http_headers*/ None, + ) + .await + .expect("discovery should succeed") + .expect("oauth support should be detected"); + + assert_eq!( + discovery.scopes_supported, + Some(vec!["read".to_string(), "write".to_string()]) + ); + } + #[tokio::test] async fn discover_streamable_http_oauth_ignores_empty_scopes() { let server = spawn_oauth_discovery_server(serde_json::json!({ 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 4407a27eeb3e..722f5f7d7875 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -4,6 +4,8 @@ use std::collections::HashMap; use std::collections::hash_map::Entry; use std::sync::Arc; use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use rmcp::ErrorData as McpError; @@ -11,10 +13,13 @@ use rmcp::ServiceExt; use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; 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; @@ -38,6 +43,7 @@ struct TestToolServer { tools: Arc>, resources: Arc>, resource_templates: Arc>, + supports_openai_form_elicitation: Arc, } const MEMO_URI: &str = "memo://codex/example-note"; @@ -65,9 +71,28 @@ 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(), Self::sync_readonly_tool(), @@ -81,6 +106,7 @@ impl TestToolServer { tools: Arc::new(tools), resources: Arc::new(resources), resource_templates: Arc::new(resource_templates), + supports_openai_form_elicitation: Arc::new(AtomicBool::new(false)), } } @@ -166,6 +192,24 @@ impl TestToolServer { tool } + fn client_capabilities_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .expect("client capabilities tool schema should deserialize"); + + let mut tool = Tool::new( + Cow::Borrowed("client_capabilities"), + Cow::Borrowed("Return capabilities advertised by the MCP client."), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool + } + fn sync_tool() -> Tool { #[expect(clippy::expect_used)] let schema: JsonObject = serde_json::from_value(json!({ @@ -396,6 +440,23 @@ struct ImageScenarioArgs { } impl ServerHandler for TestToolServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + self.supports_openai_form_elicitation.store( + request + .capabilities + .extensions + .as_ref() + .is_some_and(|extensions| extensions.contains_key("openai/form")), + Ordering::Relaxed, + ); + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { let mut capabilities = ServerCapabilities::builder() .enable_tools() @@ -481,6 +542,11 @@ impl ServerHandler for TestToolServer { context: rmcp::service::RequestContext, ) -> Result { match request.name.as_ref() { + "client_capabilities" => Ok(Self::structured_result(json!({ + "supportsOpenaiFormElicitation": self + .supports_openai_form_elicitation + .load(Ordering::Relaxed), + }))), "sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object( context.meta.0, ))), @@ -490,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/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index 49f11f0a7637..2227ee4d63c2 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use rmcp::RoleClient; use rmcp::model::ClientInfo; use rmcp::model::ClientResult; +use rmcp::model::CustomRequest; use rmcp::model::CustomResult; use rmcp::model::ElicitationAction; use rmcp::model::Meta; @@ -12,7 +13,9 @@ use rmcp::model::ServerRequest; use rmcp::service::NotificationContext; use rmcp::service::RequestContext; use rmcp::service::Service; +use serde::Deserialize; use serde::Serialize; +use serde_json::Map; use serde_json::Value; use crate::logging_client_handler::LoggingClientHandler; @@ -22,10 +25,21 @@ use crate::rmcp_client::ElicitationResponse; use crate::rmcp_client::SendElicitation; const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken"; +const OPENAI_FORM_METHOD: &str = "openai/form"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpenAiFormRequestParams { + #[serde(rename = "_meta")] + meta: Option, + message: String, + requested_schema: Value, +} #[derive(Clone)] pub(crate) struct ElicitationClientService { handler: LoggingClientHandler, + supports_openai_form: bool, send_elicitation: Arc, pause_state: ElicitationPauseState, } @@ -36,12 +50,18 @@ impl ElicitationClientService { send_elicitation: SendElicitation, pause_state: ElicitationPauseState, ) -> Self { + let supports_openai_form = client_info + .capabilities + .extensions + .as_ref() + .is_some_and(|extensions| extensions.contains_key(OPENAI_FORM_METHOD)); let send_elicitation = Arc::new(send_elicitation); Self { handler: LoggingClientHandler::new( client_info, clone_send_elicitation(Arc::clone(&send_elicitation)), ), + supports_openai_form, send_elicitation, pause_state, } @@ -73,11 +93,23 @@ impl Service for ElicitationClientService { ) -> Result { match request { ServerRequest::CreateElicitationRequest(request) => { - let response = self.create_elicitation(request.params, context).await?; + let response = self + .create_elicitation(Elicitation::Mcp(request.params), context) + .await?; // RMCP's typed CreateElicitationResult does not model result-level `_meta`. let result = elicitation_response_result(response)?; Ok(ClientResult::CustomResult(result)) } + ServerRequest::CustomRequest(request) + if request.method == OPENAI_FORM_METHOD && self.supports_openai_form => + { + let response = self + .create_elicitation(openai_form_elicitation(request)?, context) + .await?; + Ok(ClientResult::CustomResult(elicitation_response_result( + response, + )?)) + } request => { >::handle_request( &self.handler, @@ -107,6 +139,18 @@ impl Service for ElicitationClientService { } } +fn openai_form_elicitation(request: CustomRequest) -> Result { + let params = request + .params_as::() + .map_err(|err| rmcp::ErrorData::invalid_params(err.to_string(), None))? + .ok_or_else(|| rmcp::ErrorData::invalid_params("missing params", None))?; + Ok(Elicitation::OpenAiForm { + meta: params.meta, + message: params.message, + requested_schema: params.requested_schema, + }) +} + fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Elicitation { // RMCP lifts JSON-RPC `_meta` into RequestContext before invoking services. context_meta.remove(MCP_PROGRESS_TOKEN_META_KEY); @@ -114,10 +158,20 @@ fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Eli return request; } - request - .meta_mut() - .get_or_insert_with(Meta::new) - .extend(context_meta); + match &mut request { + Elicitation::Mcp(request) => request + .meta_mut() + .get_or_insert_with(Meta::new) + .extend(context_meta), + Elicitation::OpenAiForm { meta, .. } => { + let meta = meta + .get_or_insert_with(|| Value::Object(Map::new())) + .as_object_mut(); + if let Some(meta) = meta { + meta.extend(context_meta.0); + } + } + } request } @@ -165,7 +219,7 @@ mod tests { #[test] fn restore_context_meta_adds_elicitation_meta_and_removes_progress_token() { let request = restore_context_meta( - form_request(/*meta*/ None), + Elicitation::Mcp(form_request(/*meta*/ None)), meta(json!({ "progressToken": "progress-token", "persist": ["session", "always"], @@ -174,9 +228,54 @@ mod tests { assert_eq!( request, - form_request(Some(meta(json!({ + Elicitation::Mcp(form_request(Some(meta(json!({ "persist": ["session", "always"], - })))) + }))))) + ); + } + + #[test] + fn parses_openai_form_custom_requests() { + let elicitation = openai_form_elicitation(CustomRequest::new( + OPENAI_FORM_METHOD, + Some(json!({ + "message": "Select a template", + "requestedSchema": { + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + }] + } + } + } + })), + )) + .expect("valid openai/form request"); + + assert_eq!( + elicitation, + Elicitation::OpenAiForm { + meta: None, + message: "Select a template".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + }] + } + } + }), + } ); } diff --git a/codex-rs/rmcp-client/src/logging_client_handler.rs b/codex-rs/rmcp-client/src/logging_client_handler.rs index 0c3da0fe2cd1..e575966cffff 100644 --- a/codex-rs/rmcp-client/src/logging_client_handler.rs +++ b/codex-rs/rmcp-client/src/logging_client_handler.rs @@ -17,6 +17,7 @@ use tracing::error; use tracing::info; use tracing::warn; +use crate::rmcp_client::Elicitation; use crate::rmcp_client::SendElicitation; #[derive(Clone)] @@ -40,7 +41,7 @@ impl ClientHandler for LoggingClientHandler { request: CreateElicitationRequestParams, context: RequestContext, ) -> Result { - (self.send_elicitation)(context.id, request) + (self.send_elicitation)(context.id, Elicitation::Mcp(request)) .await .map(Into::into) .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None)) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index e9b01486228e..4a9b89de3c32 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -40,6 +40,7 @@ use rmcp::model::PaginatedRequestParams; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; +use rmcp::model::RequestParamsMeta; use rmcp::model::ServerResult; use rmcp::model::Tool; use rmcp::service::RoleClient; @@ -251,7 +252,24 @@ fn remaining_operation_timeout( } } -pub type Elicitation = CreateElicitationRequestParams; +#[derive(Debug, Clone, PartialEq)] +pub enum Elicitation { + Mcp(CreateElicitationRequestParams), + OpenAiForm { + meta: Option, + message: String, + requested_schema: serde_json::Value, + }, +} + +impl Elicitation { + pub fn meta(&self) -> Option<&serde_json::Map> { + match self { + Self::Mcp(request) => request.meta().map(|meta| &meta.0), + Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object), + } + } +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 8ada806ff754..94aa45c1e7dd 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -503,6 +503,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/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index f826d9f47c66..66f26f9758a9 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -496,7 +496,7 @@ mod tests { #[test] fn traced_response_item_preserves_reasoning_content_omitted_by_normal_serializer() { let item = ResponseItem::Reasoning { - id: "rs-1".to_string(), + id: Some("rs-1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -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"); @@ -515,6 +515,7 @@ mod tests { traced, json!({ "type": "reasoning", + "id": "rs-1", "summary": [{"type": "summary_text", "text": "summary"}], "content": [{"type": "text", "text": "raw reasoning"}], "encrypted_content": "encoded", diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index bbba245307f6..42e0db04d2bb 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::TurnAbortReason; use serde::Serialize; +use std::time::Duration; use crate::AgentThreadId; use crate::CodexTurnId; @@ -120,8 +121,12 @@ impl Serialize for ToolRuntimePayload<'_> { S: serde::Serializer, { match self { - ToolRuntimePayload::ExecCommandBegin(event) => event.serialize(serializer), - ToolRuntimePayload::ExecCommandEnd(event) => event.serialize(serializer), + ToolRuntimePayload::ExecCommandBegin(event) => { + ExecCommandBeginTracePayload::from(*event).serialize(serializer) + } + ToolRuntimePayload::ExecCommandEnd(event) => { + ExecCommandEndTracePayload::from(*event).serialize(serializer) + } ToolRuntimePayload::PatchApplyBegin(event) => event.serialize(serializer), ToolRuntimePayload::PatchApplyEnd(event) => event.serialize(serializer), ToolRuntimePayload::McpToolCallBegin(event) => event.serialize(serializer), @@ -139,6 +144,119 @@ impl Serialize for ToolRuntimePayload<'_> { } } +/// Rollout-trace representation of an exec begin event. +/// +/// Rollout traces share the rollout compatibility requirement that paths remain path-flavored +/// strings on disk, even though live events carry `PathUri` internally. +#[derive(Serialize)] +struct ExecCommandBeginTracePayload<'a> { + call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + process_id: Option<&'a str>, + turn_id: &'a str, + started_at_ms: i64, + command: &'a [String], + cwd: String, + parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand], + source: ExecCommandSource, + #[serde(skip_serializing_if = "Option::is_none")] + interaction_input: Option<&'a str>, +} + +impl<'a> From<&'a ExecCommandBeginEvent> for ExecCommandBeginTracePayload<'a> { + fn from(event: &'a ExecCommandBeginEvent) -> Self { + let ExecCommandBeginEvent { + call_id, + process_id, + turn_id, + started_at_ms, + command, + cwd, + parsed_cmd, + source, + interaction_input, + } = event; + Self { + call_id, + process_id: process_id.as_deref(), + turn_id, + started_at_ms: *started_at_ms, + command, + cwd: cwd.inferred_native_path_string(), + parsed_cmd, + source: *source, + interaction_input: interaction_input.as_deref(), + } + } +} + +/// Rollout-trace representation of an exec end event. +/// +/// Like [`ExecCommandBeginTracePayload`], this renders `cwd` as an inferred native path to preserve +/// the on-disk format rather than serializing the internal `PathUri`. +#[derive(Serialize)] +struct ExecCommandEndTracePayload<'a> { + call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + process_id: Option<&'a str>, + turn_id: &'a str, + completed_at_ms: i64, + command: &'a [String], + cwd: String, + parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand], + source: ExecCommandSource, + #[serde(skip_serializing_if = "Option::is_none")] + interaction_input: Option<&'a str>, + stdout: &'a str, + stderr: &'a str, + aggregated_output: &'a str, + exit_code: i32, + duration: Duration, + formatted_output: &'a str, + status: &'a ExecCommandStatus, +} + +impl<'a> From<&'a ExecCommandEndEvent> for ExecCommandEndTracePayload<'a> { + fn from(event: &'a ExecCommandEndEvent) -> Self { + let ExecCommandEndEvent { + call_id, + process_id, + turn_id, + completed_at_ms, + command, + cwd, + parsed_cmd, + source, + interaction_input, + stdout, + stderr, + aggregated_output, + exit_code, + duration, + formatted_output, + status, + } = event; + Self { + call_id, + process_id: process_id.as_deref(), + turn_id, + completed_at_ms: *completed_at_ms, + command, + cwd: cwd.inferred_native_path_string(), + parsed_cmd, + source: *source, + interaction_input: interaction_input.as_deref(), + stdout, + stderr, + aggregated_output, + exit_code: *exit_code, + duration: *duration, + formatted_output, + status, + } + } +} + pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option> { match event { EventMsg::ExecCommandBegin(event) if event.source != ExecCommandSource::UserShell => { @@ -226,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/protocol_event_tests.rs b/codex-rs/rollout-trace/src/protocol_event_tests.rs index b18c7200ac50..200c35d94d7a 100644 --- a/codex-rs/rollout-trace/src/protocol_event_tests.rs +++ b/codex-rs/rollout-trace/src/protocol_event_tests.rs @@ -1,10 +1,15 @@ use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::SubAgentActivityKind; use pretty_assertions::assert_eq; use serde_json::json; +use std::time::Duration; use super::ToolRuntimeTraceEvent; use super::tool_runtime_trace_event; @@ -44,3 +49,81 @@ fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> { ); Ok(()) } + +#[test] +fn exec_command_trace_payloads_use_inferred_native_cwd() -> anyhow::Result<()> { + // Convention inference depends on the URI spelling, not the test host, so exercise both + // Windows and POSIX paths on every platform. + let begin = EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-begin".to_string(), + process_id: Some("process-1".to_string()), + turn_id: "turn-1".to_string(), + started_at_ms: 1234, + command: vec!["pwd".to_string()], + cwd: "file:///C:/windows".parse()?, + parsed_cmd: Vec::new(), + source: ExecCommandSource::Agent, + interaction_input: None, + }); + let end = EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-end".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + completed_at_ms: 2345, + command: vec!["pwd".to_string()], + cwd: "file:///workspace/project".parse()?, + parsed_cmd: Vec::new(), + source: ExecCommandSource::UnifiedExecInteraction, + interaction_input: Some("input".to_string()), + stdout: "output".to_string(), + stderr: String::new(), + aggregated_output: "output".to_string(), + exit_code: 0, + duration: Duration::from_millis(250), + formatted_output: "output".to_string(), + status: ExecCommandStatus::Completed, + }); + + let Some(ToolRuntimeTraceEvent::Started { payload, .. }) = tool_runtime_trace_event(&begin) + else { + panic!("expected started tool runtime event"); + }; + assert_eq!( + serde_json::to_value(payload)?, + json!({ + "call_id": "call-begin", + "process_id": "process-1", + "turn_id": "turn-1", + "started_at_ms": 1234, + "command": ["pwd"], + "cwd": r"C:\windows", + "parsed_cmd": [], + "source": "agent" + }) + ); + + let Some(ToolRuntimeTraceEvent::Ended { payload, .. }) = tool_runtime_trace_event(&end) else { + panic!("expected ended tool runtime event"); + }; + assert_eq!( + serde_json::to_value(payload)?, + json!({ + "call_id": "call-end", + "turn_id": "turn-1", + "completed_at_ms": 2345, + "command": ["pwd"], + "cwd": "/workspace/project", + "parsed_cmd": [], + "source": "unified_exec_interaction", + "interaction_input": "input", + "stdout": "output", + "stderr": "", + "aggregated_output": "output", + "exit_code": 0, + "duration": {"secs": 0, "nanos": 250000000}, + "formatted_output": "output", + "status": "completed" + }) + ); + Ok(()) +} diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents.rs b/codex-rs/rollout-trace/src/reducer/tool/agents.rs index 381c475b6948..10b02f237ff9 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents.rs @@ -771,12 +771,13 @@ fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String return None; } if let Some(agent_message) = &item.agent_message { - let [content] = item.body.parts.as_slice() else { - return None; - }; - let message_content = match content { - ConversationPart::Text { text } => text, - ConversationPart::Encoded { label, value } if label == "encrypted_content" => value, + let message_content = match item.body.parts.as_slice() { + [ConversationPart::Text { text }] => text, + [ConversationPart::Encoded { label, value }] if label == "encrypted_content" => value, + [ + ConversationPart::Text { .. }, + ConversationPart::Encoded { label, value }, + ] if label == "encrypted_content" => value, _ => return None, }; return Some(( diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs b/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs index db8fcc007c90..f5a4648f8021 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs @@ -255,7 +255,13 @@ fn sub_agent_started_activity_creates_spawn_edge() -> anyhow::Result<()> { "type": "agent_message", "author": "/root", "recipient": "/root/reviewer", - "content": [{"type": "input_text", "text": "review this"}] + "content": [ + { + "type": "input_text", + "text": "Message Type: NEW_TASK\nTask name: /root/reviewer\nSender: /root\nPayload:\n" + }, + {"type": "encrypted_content", "encrypted_content": "review this"} + ] })], )?; diff --git a/codex-rs/rollout/src/compression.rs b/codex-rs/rollout/src/compression.rs index 5f0023432cbc..e64cc318a6d8 100644 --- a/codex-rs/rollout/src/compression.rs +++ b/codex-rs/rollout/src/compression.rs @@ -362,9 +362,14 @@ mod worker { let result = async { cleanup_stale_temps(codex_home.as_path()).await?; let mut stats = CompressionStats::default(); - if started_at.elapsed() < WORKER_MAX_RUNTIME { - let archived_root = codex_home.join(ARCHIVED_SESSIONS_SUBDIR); - compress_rollouts_in_root(archived_root.as_path(), started_at, &mut stats).await?; + for root in [ + codex_home.join(ARCHIVED_SESSIONS_SUBDIR), + codex_home.join(SESSIONS_SUBDIR), + ] { + if started_at.elapsed() >= WORKER_MAX_RUNTIME { + break; + } + compress_rollouts_in_root(root.as_path(), started_at, &mut stats).await?; } Ok::<_, io::Error>(stats) } diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index 682361ea9da2..60f47d078f50 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -130,7 +130,7 @@ async fn search_rollout_matches_uses_logical_path_for_compressed_rollout() -> an } #[tokio::test] -async fn worker_compresses_old_archived_rollouts_only() -> anyhow::Result<()> { +async fn worker_compresses_old_active_and_archived_rollouts() -> anyhow::Result<()> { let home = TempDir::new()?; let active_uuid = Uuid::from_u128(3); let active_id = ThreadId::from_string(&active_uuid.to_string())?; @@ -158,8 +158,8 @@ async fn worker_compresses_old_archived_rollouts_only() -> anyhow::Result<()> { worker::run(home.path().to_path_buf()).await?; - assert!(active_path.exists()); - assert!(!compressed_rollout_path(&active_path).exists()); + assert!(!active_path.exists()); + assert!(compressed_rollout_path(&active_path).exists()); assert!(!archived_path.exists()); assert!(compressed_rollout_path(&archived_path).exists()); assert!(fresh_path.exists()); @@ -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/list.rs b/codex-rs/rollout/src/list.rs index a071b76ee63e..3bf311b8168e 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -80,6 +80,8 @@ pub struct ThreadItem { pub created_at: Option, /// RFC3339 timestamp string for the most recent update (from file mtime). pub updated_at: Option, + /// RFC3339 timestamp string used for product recency ordering. + pub recency_at: Option, } #[allow(dead_code)] @@ -119,6 +121,7 @@ const USER_EVENT_SCAN_LIMIT: usize = 200; pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -141,20 +144,29 @@ pub struct ThreadListConfig<'a> { pub layout: ThreadListLayout, } -/// Pagination cursor identifying the timestamp of the last item in a page. +/// Pagination cursor identifying the last item in a page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { ts: OffsetDateTime, + id: Option, } impl Cursor { - fn new(ts: OffsetDateTime) -> Self { - Self { ts } + pub(crate) fn new(ts: OffsetDateTime) -> Self { + Self { ts, id: None } + } + + pub(crate) fn with_thread_id(ts: OffsetDateTime, id: ThreadId) -> Self { + Self { ts, id: Some(id) } } pub(crate) fn timestamp(&self) -> OffsetDateTime { self.ts } + + pub(crate) fn thread_id(&self) -> Option { + self.id + } } /// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, @@ -291,7 +303,10 @@ impl serde::Serialize for Cursor { .ts .format(&Rfc3339) .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; - serializer.serialize_str(&ts_str) + match self.id { + Some(id) => serializer.serialize_str(&format!("{ts_str}|{id}")), + None => serializer.serialize_str(&ts_str), + } } } @@ -312,7 +327,7 @@ impl From for Cursor { .timestamp_nanos_opt() .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok()) .unwrap_or(OffsetDateTime::UNIX_EPOCH); - Self::new(ts) + Self { ts, id: anchor.id } } } @@ -423,7 +438,7 @@ async fn traverse_directories_for_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_directories_for_paths_updated( root, page_size, @@ -458,7 +473,7 @@ async fn traverse_flat_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_flat_paths_updated( root, page_size, @@ -706,35 +721,48 @@ async fn traverse_flat_paths_updated( }) } -/// Pagination cursor token format: an RFC3339 timestamp. +/// Pagination cursor token format: an RFC3339 timestamp with an optional thread ID tie-breaker. pub fn parse_cursor(token: &str) -> Option { - if token.contains('|') { - return None; - } - - let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| { - let format: &[FormatItem] = - format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); - PrimitiveDateTime::parse(token, format) - .ok() - .map(PrimitiveDateTime::assume_utc) - })?; + let (timestamp, id) = match token.rsplit_once('|') { + Some((timestamp, id)) => (timestamp, Some(ThreadId::from_string(id).ok()?)), + None => (token, None), + }; - Some(Cursor::new(ts)) + let ts = OffsetDateTime::parse(timestamp, &Rfc3339) + .ok() + .or_else(|| { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + PrimitiveDateTime::parse(timestamp, format) + .ok() + .map(PrimitiveDateTime::assume_utc) + })?; + + Some(Cursor { ts, id }) } fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; let ts = match sort_key { ThreadSortKey::CreatedAt => created_ts, ThreadSortKey::UpdatedAt => { let updated_at = last.updated_at.as_deref()?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = last.recency_at.as_deref().or(last.updated_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; - Some(Cursor::new(ts)) + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + ts, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(ts)), + } } async fn build_thread_item( @@ -812,6 +840,7 @@ async fn build_thread_item( model_provider, cli_version, created_at, + recency_at: summary_updated_at.clone(), updated_at: summary_updated_at, }); } diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 19e9a9320c9a..dcb38b6d2db0 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -115,6 +115,7 @@ pub async fn extract_metadata_from_rollout( } if let Some(updated_at) = file_modified_time_utc(rollout_path).await { metadata.updated_at = updated_at; + metadata.recency_at = updated_at; } Ok(ExtractionOutcome { metadata, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 03d251e1b304..5f923f16d075 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, @@ -71,6 +72,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let mut expected = builder.build("openai"); apply_rollout_item(&mut expected, &rollout_line.item, "openai"); expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); + expected.recency_at = expected.updated_at; assert_eq!(outcome.metadata, expected); assert_eq!(outcome.memory_mode, None); @@ -87,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, @@ -153,6 +156,9 @@ fn builder_from_items_falls_back_to_filename() { let items = vec![RolloutItem::Compacted(CompactedItem { message: "noop".to_string(), replacement_history: None, + window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, })]; @@ -351,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 169f4360d317..b342d699b7b5 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -121,6 +121,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 93b21dec2410..85385bc80560 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; @@ -81,10 +82,11 @@ pub struct RolloutRecorder { #[derive(Clone)] 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, @@ -167,10 +169,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, @@ -178,6 +181,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, @@ -543,6 +553,27 @@ impl RolloutRecorder { ) .await; } + if sort_key == ThreadSortKey::RecencyAt { + if let Some(repaired_db_page) = state_db::list_threads_db( + state_db_ctx.as_deref(), + codex_home, + page_size, + cursor, + sort_key, + sort_direction, + allowed_sources, + model_providers, + cwd_filters, + /*parent_thread_id*/ None, + archived, + search_term, + ) + .await + { + return Ok(repaired_db_page.into()); + } + return Ok(db_page.into()); + } codex_state::record_fallback( "list_threads", "metadata_filter", @@ -675,6 +706,7 @@ impl RolloutRecorder { ) -> std::io::Result { let (file, deferred_log_file_info, rollout_path, meta) = match params { RolloutRecorderParams::Create { + session_id, conversation_id, forked_from_id, parent_thread_id, @@ -686,7 +718,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!( @@ -698,7 +730,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, @@ -708,7 +741,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), @@ -985,6 +1018,11 @@ fn truncate_fs_page( let cursor_token = match sort_key { ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?, ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(), + ThreadSortKey::RecencyAt => item + .recency_at + .as_deref() + .or(item.updated_at.as_deref())? + .to_string(), }; parse_cursor(cursor_token.as_str()) }); @@ -1050,6 +1088,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt cli_version, created_at, updated_at, + recency_at, } = state_item; if item.first_user_message.is_none() { @@ -1097,6 +1136,9 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.updated_at.is_none() { item.updated_at = updated_at; } + if recency_at.is_some() { + item.recency_at = recency_at; + } } #[allow(clippy::too_many_arguments)] @@ -1273,9 +1315,20 @@ async fn list_threads_from_files_asc( .collect::>(); if let Some(cursor) = cursor { - let anchor = cursor.timestamp(); - all_items - .retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor)); + let anchor = ( + cursor.timestamp(), + cursor + .thread_id() + .and_then(|id| uuid::Uuid::parse_str(&id.to_string()).ok()), + ); + all_items.retain(|item| { + thread_item_sort_key(item, sort_key).is_some_and(|key| match anchor.1 { + Some(anchor_id) if sort_key == ThreadSortKey::RecencyAt => { + key > (anchor.0, anchor_id) + } + _ => key.0 > anchor.0, + }) + }); } let more_matches_available = all_items.len() > page_size || reached_scan_cap; @@ -1333,14 +1386,27 @@ fn thread_item_sort_key( let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = item + .recency_at + .as_deref() + .or(item.updated_at.as_deref()) + .or(item.created_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; Some((timestamp, id)) } fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option { - let (timestamp, _id) = thread_item_sort_key(item, sort_key)?; - let cursor_token = timestamp.format(&Rfc3339).ok()?; - parse_cursor(cursor_token.as_str()) + let (timestamp, id) = thread_item_sort_key(item, sort_key)?; + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + timestamp, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(timestamp)), + } } struct LogFileInfo { @@ -1730,6 +1796,7 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), + recency_at: Some(item.recency_at.to_rfc3339_opts(SecondsFormat::Millis, true)), } } @@ -1770,7 +1837,7 @@ async fn resume_candidate_matches_cwd( if let Ok((items, _, _)) = RolloutRecorder::load_rollout_items(rollout_path).await && let Some(latest_turn_context_cwd) = items.iter().rev().find_map(|item| match item { - RolloutItem::TurnContext(turn_context) => Some(turn_context.cwd.as_path()), + RolloutItem::TurnContext(turn_context) => Some(&turn_context.cwd), RolloutItem::SessionMeta(_) | RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) @@ -1778,7 +1845,7 @@ async fn resume_candidate_matches_cwd( | RolloutItem::EventMsg(_) => None, }) { - return cwd_matches(latest_turn_context_cwd, cwd); + return cwd_matches(latest_turn_context_cwd.as_path(), cwd); } metadata::extract_metadata_from_rollout(rollout_path, default_provider) diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 561dfbfd3cf8..367d76e3e6b9 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; @@ -46,6 +47,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, @@ -146,7 +149,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)?; @@ -211,7 +214,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 { .. }) @@ -235,6 +241,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": ".", @@ -298,6 +305,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": ".", @@ -366,6 +374,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, @@ -377,7 +386,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?; @@ -422,10 +432,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(), @@ -986,6 +999,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: None, cli_version: None, created_at: None, + recency_at: Some("2025-01-03T15:59:00.000Z".to_string()), updated_at: None, }; let state_item = ThreadItem { @@ -1005,6 +1019,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: Some("state-provider".to_string()), cli_version: Some("state-version".to_string()), created_at: Some("2025-01-03T16:00:00Z".to_string()), + recency_at: Some("2025-01-03T16:00:30.001Z".to_string()), updated_at: Some("2025-01-03T16:01:02.003Z".to_string()), }; @@ -1031,6 +1046,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi assert_eq!(item.model_provider.as_deref(), Some("state-provider")); assert_eq!(item.cli_version.as_deref(), Some("state-version")); assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); + assert_eq!(item.recency_at.as_deref(), Some("2025-01-03T16:00:30.001Z")); assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); } @@ -1138,7 +1154,8 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re timestamp: "2025-01-03T13:00:01Z".to_string(), item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: latest_cwd.clone(), + cwd: serde_json::from_value(serde_json::json!(&latest_cwd)) + .expect("absolute latest cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -1152,6 +1169,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 97496d394e77..33e4ae4a593d 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.rs b/codex-rs/rollout/src/state_db.rs index ab93305fa642..96fbf7af1c73 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -290,7 +290,10 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000; let millis = i64::try_from(millis).ok()?; let ts = chrono::DateTime::::from_timestamp_millis(millis)?; - Some(codex_state::Anchor { ts }) + Some(codex_state::Anchor { + ts, + id: cursor.thread_id(), + }) } pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { @@ -336,6 +339,7 @@ pub async fn list_thread_ids_db( match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, allowed_sources.as_slice(), model_providers.as_deref(), @@ -401,6 +405,7 @@ pub async fn list_threads_db( sort_key: match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, sort_direction: match sort_direction { SortDirection::Asc => codex_state::SortDirection::Asc, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index a84ede199b46..4b2ae4f25c26 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -28,6 +28,22 @@ fn cursor_to_anchor_normalizes_timestamp_format() { .expect("nanosecond"); assert_eq!(anchor.ts, expected_ts); + assert_eq!(anchor.id, None); +} + +#[test] +fn cursor_to_anchor_preserves_recency_tie_breaker() { + let id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread id should parse"); + let token = format!("2026-01-27T12:34:56Z|{id}"); + let cursor = parse_cursor(&token).expect("cursor should parse"); + let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); + + assert_eq!(anchor.id, Some(id)); + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"{token}\"") + ); } #[tokio::test] @@ -142,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 f199a487a77e..2deb8c485dea 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -297,6 +297,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": ".", @@ -370,6 +371,7 @@ fn write_goal_started_session_file( "timestamp": ts_str, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts_str, "cwd": ".", @@ -451,6 +453,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": ".", @@ -483,8 +486,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) @@ -613,6 +617,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), + recency_at: updated_times.first().cloned().flatten(), updated_at: updated_times.first().cloned().flatten(), }, ThreadItem { @@ -632,6 +637,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), + recency_at: updated_times.get(1).cloned().flatten(), updated_at: updated_times.get(1).cloned().flatten(), }, ThreadItem { @@ -651,6 +657,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), + recency_at: updated_times.get(2).cloned().flatten(), updated_at: updated_times.get(2).cloned().flatten(), }, ], @@ -763,6 +770,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -782,6 +790,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], @@ -837,6 +846,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), + recency_at: updated_page2.first().cloned().flatten(), updated_at: updated_page2.first().cloned().flatten(), }, ThreadItem { @@ -856,6 +866,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), + recency_at: updated_page2.get(1).cloned().flatten(), updated_at: updated_page2.get(1).cloned().flatten(), }, ], @@ -903,6 +914,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), + recency_at: updated_page3.first().cloned().flatten(), updated_at: updated_page3.first().cloned().flatten(), }], next_cursor: None, @@ -1075,6 +1087,7 @@ async fn test_get_thread_contents() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), + recency_at: page.items[0].updated_at.clone(), updated_at: page.items[0].updated_at.clone(), }], next_cursor: None, @@ -1088,6 +1101,7 @@ async fn test_get_thread_contents() { "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts, "cwd": ".", @@ -1267,6 +1281,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, @@ -1314,7 +1329,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)?)?; @@ -1431,6 +1446,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -1450,6 +1466,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index ccdcd876814a..3185d782baa6 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -17,6 +17,7 @@ codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } +codex-windows-sandbox = { workspace = true } dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } @@ -25,6 +26,9 @@ tracing = { workspace = true, features = ["log"] } url = { workspace = true } which = { workspace = true } +[target.'cfg(windows)'.dependencies] +codex-utils-home-dir = { workspace = true } + [dev-dependencies] anyhow = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/sandboxing/src/denial.rs b/codex-rs/sandboxing/src/denial.rs new file mode 100644 index 000000000000..f355fa1c023b --- /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 7210acd0df3a..0688d0f9204a 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,16 +1,20 @@ #[cfg(target_os = "linux")] mod bwrap; +mod denial; pub mod landlock; mod manager; pub mod policy_transforms; #[cfg(target_os = "macos")] pub mod seatbelt; +mod windows; #[cfg(target_os = "linux")] 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; pub use manager::SandboxManager; pub use manager::SandboxTransformError; @@ -20,6 +24,12 @@ pub use manager::SandboxablePreference; pub use manager::compatibility_sandbox_policy_for_permission_profile; pub use manager::get_platform_sandbox; pub use manager::with_managed_mitm_ca_readable_root; +pub use windows::WindowsSandboxFilesystemOverrides; +pub use windows::permission_profile_supports_windows_restricted_token_sandbox; +pub use windows::resolve_windows_elevated_filesystem_overrides; +pub use windows::resolve_windows_restricted_token_filesystem_overrides; +pub use windows::unsupported_windows_restricted_token_sandbox_reason; +pub use windows::windows_sandbox_uses_elevated_backend; use codex_protocol::error::CodexErr; @@ -40,6 +50,9 @@ impl From for CodexErr { SandboxTransformError::MissingLinuxSandboxExecutable => { CodexErr::LandlockSandboxExecutableNotProvided } + SandboxTransformError::EnvironmentNetworkProxy(message) => { + CodexErr::UnsupportedOperation(message) + } #[cfg(target_os = "linux")] SandboxTransformError::Wsl1UnsupportedForBubblewrap => { CodexErr::UnsupportedOperation(crate::bwrap::WSL1_BWRAP_WARNING.to_string()) @@ -48,6 +61,10 @@ impl From for CodexErr { SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation( "seatbelt sandbox is only available on macOS".to_string(), ), + #[cfg(target_os = "windows")] + SandboxTransformError::WindowsSandboxPreparation(message) => { + CodexErr::UnsupportedOperation(message) + } } } } diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 35ebaeac5b0d..29b1177b11de 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -7,6 +7,12 @@ use crate::landlock::allow_network_for_proxy; use crate::landlock::create_linux_sandbox_command_args_for_permission_profile; use crate::policy_transforms::effective_permission_profile; use crate::policy_transforms::should_require_platform_sandbox; +#[cfg(target_os = "windows")] +use crate::resolve_windows_elevated_filesystem_overrides; +#[cfg(target_os = "windows")] +use crate::resolve_windows_restricted_token_filesystem_overrides; +#[cfg(target_os = "windows")] +use crate::windows_sandbox_uses_elevated_backend; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -21,6 +27,9 @@ use std::ffi::OsString; use std::io; use std::path::Path; +#[cfg(target_os = "windows")] +const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &["USERNAME", "USERPROFILE"]; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SandboxType { None, @@ -100,10 +109,11 @@ pub struct SandboxCommand { #[derive(Debug)] pub struct SandboxExecRequest { pub command: Vec, - pub cwd: AbsolutePathBuf, - pub sandbox_policy_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_policy_cwd: PathUri, pub env: HashMap, pub network: Option, + pub network_environment_id: Option, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -121,6 +131,7 @@ pub struct SandboxTransformRequest<'a> { pub permissions: &'a PermissionProfile, pub sandbox: SandboxType, pub enforce_managed_network: bool, + pub environment_id: Option<&'a str>, // TODO(viyatb): Evaluate switching this to Option> // to make shared ownership explicit across runtime/sandbox plumbing. pub network: Option<&'a NetworkProxy>, @@ -131,6 +142,62 @@ pub struct SandboxTransformRequest<'a> { pub windows_sandbox_private_desktop: bool, } +/// Bundled arguments for a sandbox transformation whose result will be spawned +/// directly from argv. +/// +/// Direct-spawn callers will not run a later platform-specific launcher, so the +/// returned command must encode any sandbox wrapper it needs. +pub struct SandboxDirectSpawnTransformRequest<'a> { + pub transform: SandboxTransformRequest<'a>, + pub workspace_roots: &'a [AbsolutePathBuf], +} + +// TODO(anp): Revisit this preparation type once this module's PathUri migration is complete. +struct PendingSandboxedExecRequest { + native_command_cwd: AbsolutePathBuf, + native_sandbox_policy_cwd: AbsolutePathBuf, + effective_permission_profile: PermissionProfile, + effective_file_system_policy: FileSystemSandboxPolicy, + effective_network_policy: NetworkSandboxPolicy, +} + +impl PendingSandboxedExecRequest { + fn new( + command_cwd: &PathUri, + sandbox_policy_cwd: &PathUri, + effective_permission_profile: PermissionProfile, + managed_mitm_ca_trust_bundle_path: Option<&AbsolutePathBuf>, + ) -> Result { + // TODO(anp): Move PathUri conversion into the platform sandbox implementations. + let native_command_cwd = command_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidCommandCwd { + cwd: command_cwd.clone(), + source, + } + })?; + let native_sandbox_policy_cwd = sandbox_policy_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidSandboxPolicyCwd { + cwd: sandbox_policy_cwd.clone(), + source, + } + })?; + let effective_permission_profile = with_managed_mitm_ca_readable_root( + effective_permission_profile, + managed_mitm_ca_trust_bundle_path, + native_sandbox_policy_cwd.as_path(), + ); + let (effective_file_system_policy, effective_network_policy) = + effective_permission_profile.to_runtime_permissions(); + Ok(Self { + native_command_cwd, + native_sandbox_policy_cwd, + effective_permission_profile, + effective_file_system_policy, + effective_network_policy, + }) + } +} + #[derive(Debug)] pub enum SandboxTransformError { InvalidCommandCwd { @@ -142,10 +209,13 @@ pub enum SandboxTransformError { source: io::Error, }, MissingLinuxSandboxExecutable, + EnvironmentNetworkProxy(String), #[cfg(target_os = "linux")] Wsl1UnsupportedForBubblewrap, #[cfg(not(target_os = "macos"))] SeatbeltUnavailable, + #[cfg(target_os = "windows")] + WindowsSandboxPreparation(String), } impl std::fmt::Display for SandboxTransformError { @@ -164,10 +234,17 @@ impl std::fmt::Display for SandboxTransformError { Self::MissingLinuxSandboxExecutable => { write!(f, "missing codex-linux-sandbox executable path") } + Self::EnvironmentNetworkProxy(err) => { + write!(f, "failed to prepare environment network proxy: {err}") + } #[cfg(target_os = "linux")] Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"), #[cfg(not(target_os = "macos"))] Self::SeatbeltUnavailable => write!(f, "seatbelt sandbox is only available on macOS"), + #[cfg(target_os = "windows")] + Self::WindowsSandboxPreparation(err) => { + write!(f, "failed to prepare windows sandbox wrapper: {err}") + } } } } @@ -178,10 +255,13 @@ impl std::error::Error for SandboxTransformError { Self::InvalidCommandCwd { source, .. } | Self::InvalidSandboxPolicyCwd { source, .. } => Some(source), Self::MissingLinuxSandboxExecutable => None, + Self::EnvironmentNetworkProxy(_) => None, #[cfg(target_os = "linux")] Self::Wsl1UnsupportedForBubblewrap => None, #[cfg(not(target_os = "macos"))] Self::SeatbeltUnavailable => None, + #[cfg(target_os = "windows")] + Self::WindowsSandboxPreparation(_) => None, } } } @@ -202,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, + ), } } @@ -232,6 +324,7 @@ impl SandboxManager { permissions, sandbox, enforce_managed_network, + environment_id, network, sandbox_policy_cwd, codex_linux_sandbox_exe, @@ -239,103 +332,279 @@ impl SandboxManager { windows_sandbox_level, windows_sandbox_private_desktop, } = request; - let native_command_cwd = command.cwd.to_abs_path().map_err(|source| { - SandboxTransformError::InvalidCommandCwd { - cwd: command.cwd.clone(), - source, - } - })?; - let native_sandbox_policy_cwd = sandbox_policy_cwd.to_abs_path().map_err(|source| { - SandboxTransformError::InvalidSandboxPolicyCwd { - cwd: sandbox_policy_cwd.clone(), - source, - } - })?; let additional_permissions = command.additional_permissions.take(); let managed_mitm_ca_trust_bundle_path = network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); - let effective_permission_profile = + let base_effective_permission_profile = effective_permission_profile(permissions, additional_permissions.as_ref()); - let effective_permission_profile = with_managed_mitm_ca_readable_root( - effective_permission_profile, + let pending_sandboxed_request = PendingSandboxedExecRequest::new( + &command.cwd, + sandbox_policy_cwd, + base_effective_permission_profile.clone(), managed_mitm_ca_trust_bundle_path.as_ref(), - native_sandbox_policy_cwd.as_path(), ); - let (effective_file_system_policy, effective_network_policy) = - effective_permission_profile.to_runtime_permissions(); + let (base_file_system_policy, base_network_policy) = + base_effective_permission_profile.to_runtime_permissions(); let mut argv = Vec::with_capacity(1 + command.args.len()); argv.push(command.program); argv.extend(command.args.into_iter().map(OsString::from)); - let (argv, arg0_override) = match sandbox { - SandboxType::None => (os_argv_to_strings(argv), None), + let (argv, arg0_override, pending_sandboxed_request) = match sandbox { + SandboxType::None => (os_argv_to_strings(argv), None, None), #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => { use crate::seatbelt::CreateSeatbeltCommandArgsParams; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt::create_seatbelt_command_args; + let pending = pending_sandboxed_request?; let mut args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command: os_argv_to_strings(argv), - file_system_sandbox_policy: &effective_file_system_policy, - network_sandbox_policy: effective_network_policy, - sandbox_policy_cwd: native_sandbox_policy_cwd.as_path(), + file_system_sandbox_policy: &pending.effective_file_system_policy, + network_sandbox_policy: pending.effective_network_policy, + sandbox_policy_cwd: pending.native_sandbox_policy_cwd.as_path(), enforce_managed_network, + environment_id, network, extra_allow_unix_sockets: &[], - }); + }) + .map_err(SandboxTransformError::EnvironmentNetworkProxy)?; let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); full_command.append(&mut args); - (full_command, None) + (full_command, None, Some(pending)) } #[cfg(not(target_os = "macos"))] SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), SandboxType::LinuxSeccomp => { + let pending = pending_sandboxed_request?; let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); #[cfg(target_os = "linux")] ensure_linux_bubblewrap_is_supported( - &effective_file_system_policy, + &pending.effective_file_system_policy, use_legacy_landlock, allow_proxy_network, is_wsl1(), )?; let mut args = create_linux_sandbox_command_args_for_permission_profile( os_argv_to_strings(argv), - native_command_cwd.as_path(), - &effective_permission_profile, - native_sandbox_policy_cwd.as_path(), + pending.native_command_cwd.as_path(), + &pending.effective_permission_profile, + pending.native_sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_proxy_network, ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(os_string_to_command_component(exe.as_os_str().to_owned())); full_command.append(&mut args); - (full_command, Some(linux_sandbox_arg0_override(exe))) + ( + full_command, + Some(linux_sandbox_arg0_override(exe)), + Some(pending), + ) } #[cfg(target_os = "windows")] - SandboxType::WindowsRestrictedToken => (os_argv_to_strings(argv), None), + SandboxType::WindowsRestrictedToken => ( + os_argv_to_strings(argv), + None, + Some(pending_sandboxed_request?), + ), #[cfg(not(target_os = "windows"))] - SandboxType::WindowsRestrictedToken => (os_argv_to_strings(argv), None), + SandboxType::WindowsRestrictedToken => ( + os_argv_to_strings(argv), + None, + Some(pending_sandboxed_request?), + ), }; + // Unsandboxed exec-server requests may have foreign cwd values that cannot be prepared + // locally, but their effective permissions must still be preserved. In that case, carry + // forward the base profile and its derived runtime policies. + let (permission_profile, file_system_sandbox_policy, network_sandbox_policy) = + pending_sandboxed_request.map_or( + ( + base_effective_permission_profile, + base_file_system_policy, + base_network_policy, + ), + |pending| { + ( + pending.effective_permission_profile, + pending.effective_file_system_policy, + pending.effective_network_policy, + ) + }, + ); + Ok(SandboxExecRequest { command: argv, - cwd: native_command_cwd, - sandbox_policy_cwd: native_sandbox_policy_cwd, + cwd: command.cwd, + sandbox_policy_cwd: sandbox_policy_cwd.clone(), env: command.env, network: network.cloned(), + network_environment_id: environment_id.map(str::to_string), sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - permission_profile: effective_permission_profile, - file_system_sandbox_policy: effective_file_system_policy, - network_sandbox_policy: effective_network_policy, + permission_profile, + file_system_sandbox_policy, + network_sandbox_policy, arg0: arg0_override, }) } + + pub fn transform_for_direct_spawn( + &self, + request: SandboxDirectSpawnTransformRequest<'_>, + ) -> Result { + #[cfg(target_os = "windows")] + { + let codex_home = codex_utils_home_dir::find_codex_home() + .map_err(|err| SandboxTransformError::WindowsSandboxPreparation(err.to_string()))?; + self.transform_for_direct_spawn_with_codex_home(request, codex_home.as_path()) + } + + #[cfg(not(target_os = "windows"))] + { + self.transform(request.transform) + } + } + + #[cfg(target_os = "windows")] + fn transform_for_direct_spawn_with_codex_home( + &self, + request: SandboxDirectSpawnTransformRequest<'_>, + codex_home: &Path, + ) -> Result { + let workspace_roots = request.workspace_roots; + let mut request = self.transform(request.transform)?; + if request.sandbox == SandboxType::WindowsRestrictedToken { + wrap_windows_sandbox_exec_request_for_direct_spawn( + &mut request, + workspace_roots, + codex_home, + )?; + } + Ok(request) + } +} + +#[cfg(target_os = "windows")] +fn wrap_windows_sandbox_exec_request_for_direct_spawn( + request: &mut SandboxExecRequest, + workspace_roots: &[AbsolutePathBuf], + codex_home: &Path, +) -> Result<(), SandboxTransformError> { + // TODO(anp): Keep PathUri through the Windows sandbox wrapper boundary. + let native_cwd = + request + .cwd + .to_abs_path() + .map_err(|source| SandboxTransformError::InvalidCommandCwd { + cwd: request.cwd.clone(), + source, + })?; + let native_sandbox_policy_cwd = request.sandbox_policy_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidSandboxPolicyCwd { + cwd: request.sandbox_policy_cwd.clone(), + source, + } + })?; + let Some(program) = request.command.first_mut() else { + return Err(SandboxTransformError::WindowsSandboxPreparation( + "sandbox command was empty".to_string(), + )); + }; + let source = std::path::PathBuf::from(&program); + let helper = codex_windows_sandbox::resolve_exe_for_launch(source.as_path(), codex_home); + *program = helper.to_string_lossy().into_owned(); + + let inner_command = std::mem::take(&mut request.command); + let proxy_enforced = request.network.is_some(); + let use_elevated = + windows_sandbox_uses_elevated_backend(request.windows_sandbox_level, proxy_enforced); + let overrides = if use_elevated { + resolve_windows_elevated_filesystem_overrides( + request.sandbox, + &request.permission_profile, + &native_sandbox_policy_cwd, + use_elevated, + ) + } else { + resolve_windows_restricted_token_filesystem_overrides( + request.sandbox, + &request.permission_profile, + &native_sandbox_policy_cwd, + request.windows_sandbox_level, + ) + } + .map_err(SandboxTransformError::WindowsSandboxPreparation)?; + let empty_paths: &[AbsolutePathBuf] = &[]; + let read_roots_override = overrides + .as_ref() + .and_then(|overrides| overrides.read_roots_override.as_deref()); + let read_roots_include_platform_defaults = overrides + .as_ref() + .is_some_and(|overrides| overrides.read_roots_include_platform_defaults); + let write_roots_override = overrides + .as_ref() + .and_then(|overrides| overrides.write_roots_override.as_deref()); + let deny_read_paths_override = overrides.as_ref().map_or(empty_paths, |overrides| { + overrides.additional_deny_read_paths.as_slice() + }); + let deny_write_paths_override = overrides.as_ref().map_or(empty_paths, |overrides| { + overrides.additional_deny_write_paths.as_slice() + }); + let mut wrapper_args = + codex_windows_sandbox::create_windows_sandbox_command_args_for_permission_profile( + inner_command, + &native_cwd, + workspace_roots, + &request.env, + &request.permission_profile, + request.windows_sandbox_level, + request.windows_sandbox_private_desktop, + proxy_enforced, + read_roots_override, + read_roots_include_platform_defaults, + write_roots_override, + deny_read_paths_override, + deny_write_paths_override, + codex_home, + ); + + request.command = Vec::with_capacity(1 + wrapper_args.len()); + request.command.push(source.to_string_lossy().into_owned()); + request.command.append(&mut wrapper_args); + request.sandbox = SandboxType::None; + request.arg0 = None; + add_windows_sandbox_wrapper_setup_env(&mut request.env); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn add_windows_sandbox_wrapper_setup_env(env: &mut HashMap) { + add_windows_sandbox_wrapper_setup_env_from_vars(env, std::env::vars_os()); +} + +#[cfg(target_os = "windows")] +fn add_windows_sandbox_wrapper_setup_env_from_vars( + env: &mut HashMap, + vars: impl IntoIterator, +) { + for (key, value) in vars { + let key = key.to_string_lossy().into_owned(); + if !WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST + .iter() + .any(|allowed| key.eq_ignore_ascii_case(allowed)) + { + continue; + } + env.retain(|existing, _| !existing.eq_ignore_ascii_case(&key)); + env.insert(key, value.to_string_lossy().into_owned()); + } } pub fn compatibility_sandbox_policy_for_permission_profile( diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index d76a1fd325d0..64fc87b6e4b9 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -1,4 +1,6 @@ use super::SandboxCommand; +#[cfg(target_os = "windows")] +use super::SandboxDirectSpawnTransformRequest; use super::SandboxManager; use super::SandboxTransformRequest; use super::SandboxType; @@ -72,10 +74,13 @@ fn restricted_file_system_uses_platform_sandbox_without_managed_network() { } #[test] -fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { +fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_policy() { let manager = SandboxManager::new(); - let cwd = AbsolutePathBuf::current_dir().expect("current dir"); - let cwd_uri = PathUri::from_abs_path(&cwd); + let cwd_uri = if cfg!(windows) { + PathUri::parse("file:///workspace/remote").expect("POSIX path URI") + } else { + PathUri::parse("file:///C:/workspace/remote").expect("Windows path URI") + }; let permissions = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, @@ -92,6 +97,7 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -101,8 +107,8 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() }) .expect("transform"); - assert_eq!(exec_request.cwd, cwd); - assert_eq!(exec_request.sandbox_policy_cwd, cwd); + assert_eq!(exec_request.cwd, cwd_uri); + assert_eq!(exec_request.sandbox_policy_cwd, cwd_uri); assert_eq!( exec_request.file_system_sandbox_policy, FileSystemSandboxPolicy::unrestricted() @@ -146,6 +152,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -215,6 +222,7 @@ fn transform_additional_permissions_preserves_denied_entries() { permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -311,6 +319,7 @@ fn transform_linux_seccomp_request( permissions: &permissions, sandbox: SandboxType::LinuxSeccomp, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: Some(codex_linux_sandbox_exe), @@ -410,3 +419,154 @@ fn transform_linux_seccomp_uses_helper_alias_when_launcher_is_not_helper_path() assert_eq!(exec_request.arg0, Some("codex-linux-sandbox".to_string())); } + +#[cfg(target_os = "windows")] +#[test] +fn transform_for_direct_spawn_windows_preserves_only_wrapper_setup_identity() { + let mut env = HashMap::from([ + ("Path".to_string(), r"C:\Windows\System32".to_string()), + ("username".to_string(), "wrong-user".to_string()), + ("UserProfile".to_string(), r"C:\wrong".to_string()), + ]); + + super::add_windows_sandbox_wrapper_setup_env_from_vars( + &mut env, + [ + ("USERNAME", "alice"), + ("USERPROFILE", r"C:\Users\alice"), + ("OPENAI_API_KEY", "secret"), + ] + .map(|(key, value)| { + ( + std::ffi::OsString::from(key), + std::ffi::OsString::from(value), + ) + }), + ); + + assert_eq!( + env, + HashMap::from([ + ("Path".to_string(), r"C:\Windows\System32".to_string()), + ("USERNAME".to_string(), "alice".to_string()), + ("USERPROFILE".to_string(), r"C:\Users\alice".to_string()), + ]) + ); +} + +#[cfg(target_os = "windows")] +#[test] +fn transform_for_direct_spawn_windows_materializes_inner_helper() { + let codex_home = tempfile::TempDir::new().expect("codex home"); + let helper_dir = tempfile::TempDir::new().expect("helper dir"); + let configured_helper = helper_dir.path().join("configured-codex-helper.exe"); + std::fs::write(&configured_helper, b"helper").expect("write configured helper"); + let cwd = AbsolutePathBuf::from_absolute_path(helper_dir.path()).expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let blocked = cwd.join("blocked"); + std::fs::create_dir_all(blocked.as_path()).expect("create blocked path"); + let permissions = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: blocked }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + let other_workspace = tempfile::TempDir::new().expect("other workspace"); + let other_workspace_root = AbsolutePathBuf::from_absolute_path(other_workspace.path()) + .expect("absolute other workspace"); + let workspace_roots = vec![cwd, other_workspace_root]; + let manager = SandboxManager::new(); + let exec_request = manager + .transform_for_direct_spawn_with_codex_home( + SandboxDirectSpawnTransformRequest { + workspace_roots: workspace_roots.as_slice(), + transform: SandboxTransformRequest { + command: SandboxCommand { + program: configured_helper.as_os_str().to_owned(), + args: vec!["--codex-run-as-fs-helper".to_string()], + cwd: cwd_uri.clone(), + env: HashMap::from([( + "Path".to_string(), + r"C:\Windows\System32".to_string(), + )]), + additional_permissions: None, + }, + permissions: &permissions, + sandbox: SandboxType::WindowsRestrictedToken, + enforce_managed_network: false, + environment_id: None, + network: None, + sandbox_policy_cwd: &cwd_uri, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Elevated, + windows_sandbox_private_desktop: false, + }, + }, + codex_home.path(), + ) + .expect("transform for direct spawn"); + + let separator_index = exec_request + .command + .iter() + .position(|arg| arg == "--") + .expect("wrapper argv separator"); + let materialized_helper = std::path::PathBuf::from(&exec_request.command[separator_index + 1]); + assert_eq!(exec_request.sandbox, SandboxType::None); + assert_eq!( + exec_request.command.first(), + Some(&configured_helper.display().to_string()) + ); + assert!( + exec_request + .command + .iter() + .any(|arg| arg == "--run-as-windows-sandbox") + ); + assert!( + exec_request + .command + .iter() + .any(|arg| arg == "--deny-read-paths-json") + ); + assert_eq!( + exec_request.command[separator_index + 2], + "--codex-run-as-fs-helper" + ); + assert_eq!( + exec_request + .command + .windows(2) + .filter_map(|args| { + (args[0] == "--workspace-root").then_some(std::path::PathBuf::from(&args[1])) + }) + .collect::>(), + workspace_roots + .iter() + .map(|root| root.as_path().to_path_buf()) + .collect::>() + ); + assert_eq!( + materialized_helper + .parent() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new(".sandbox-bin")) + ); + assert!(materialized_helper.exists()); +} diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index 7057ec46b96a..d3233e705b3e 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -104,8 +104,9 @@ struct UnixSocketPathParam { fn proxy_policy_inputs( network: Option<&NetworkProxy>, + environment_id: Option<&str>, extra_allow_unix_sockets: &[AbsolutePathBuf], -) -> ProxyPolicyInputs { +) -> Result { let extra_allowed = extra_allow_unix_sockets .iter() .filter_map(|socket_path| normalize_path_for_sandbox(socket_path.as_path())) @@ -114,7 +115,9 @@ fn proxy_policy_inputs( match network { Some(network) => { let mut env = HashMap::new(); - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, environment_id) + .map_err(|err| err.to_string())?; let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { UnixDomainSocketPolicy::AllowAll } else { @@ -136,19 +139,19 @@ fn proxy_policy_inputs( allowed.extend(extra_allowed); UnixDomainSocketPolicy::Restricted { allowed } }; - ProxyPolicyInputs { + Ok(ProxyPolicyInputs { ports: proxy_loopback_ports_from_env(&env), has_proxy_config: has_proxy_url_env_vars(&env), allow_local_binding: network.allow_local_binding(), unix_domain_socket_policy, - } + }) } - None => ProxyPolicyInputs { + None => Ok(ProxyPolicyInputs { unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { allowed: extra_allowed, }, ..Default::default() - }, + }), } } @@ -572,7 +575,7 @@ fn create_seatbelt_command_args_for_legacy_policy( sandbox_policy_cwd: &Path, enforce_managed_network: bool, network: Option<&NetworkProxy>, -) -> Vec { +) -> Result, String> { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( sandbox_policy, sandbox_policy_cwd, @@ -583,6 +586,7 @@ fn create_seatbelt_command_args_for_legacy_policy( network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, + environment_id: None, network, extra_allow_unix_sockets: &[], }) @@ -595,17 +599,21 @@ pub struct CreateSeatbeltCommandArgsParams<'a> { pub network_sandbox_policy: NetworkSandboxPolicy, pub sandbox_policy_cwd: &'a Path, pub enforce_managed_network: bool, + pub environment_id: Option<&'a str>, pub network: Option<&'a NetworkProxy>, pub extra_allow_unix_sockets: &'a [AbsolutePathBuf], } -pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -> Vec { +pub fn create_seatbelt_command_args( + args: CreateSeatbeltCommandArgsParams<'_>, +) -> Result, String> { let CreateSeatbeltCommandArgsParams { command, file_system_sandbox_policy, network_sandbox_policy, sandbox_policy_cwd, enforce_managed_network, + environment_id, network, extra_allow_unix_sockets, } = args; @@ -701,7 +709,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) - } }; - let proxy = proxy_policy_inputs(network, extra_allow_unix_sockets); + let proxy = proxy_policy_inputs(network, environment_id, extra_allow_unix_sockets)?; let network_policy = dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); @@ -737,7 +745,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) - seatbelt_args.extend(definition_args); seatbelt_args.push("--".to_string()); seatbelt_args.extend(command); - seatbelt_args + Ok(seatbelt_args) } #[cfg(test)] diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index 337eb56378ea..e0ff0e66523b 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -205,9 +205,11 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &[], - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); let unreadable_roots = file_system_policy.get_unreadable_roots_with_cwd(Path::new("/")); @@ -277,9 +279,11 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &[], - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); let readable_roots = file_system_policy.get_readable_roots_with_cwd(Path::new("/")); @@ -392,7 +396,8 @@ fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() cwd.as_path(), /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let policy = &args[1]; assert!(policy.contains("(allow user-preference-read)")); assert!(!policy.contains("(allow user-preference-write)")); @@ -580,9 +585,11 @@ fn create_seatbelt_args_allowlists_explicit_unix_socket_paths_without_proxy() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets, - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); assert!( @@ -638,9 +645,11 @@ async fn create_seatbelt_args_merges_proxy_and_explicit_unix_socket_paths() -> a network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + environment_id: None, network: Some(&network_proxy), extra_allow_unix_sockets: &extra_allow_unix_sockets, - }); + }) + .unwrap(); let expected_explicit_socket = normalize_path_for_sandbox(Path::new(explicit_socket)) .expect("explicit socket root should normalize"); @@ -679,9 +688,11 @@ fn create_seatbelt_args_preserves_full_network_with_explicit_unix_socket_paths() network_sandbox_policy: NetworkSandboxPolicy::Enabled, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets, - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); assert!( @@ -869,7 +880,8 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let policy_text = seatbelt_policy_arg(&args); assert!( @@ -1008,7 +1020,8 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -1044,7 +1057,8 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -1108,7 +1122,8 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_metadata_name_r repo_root.as_path(), /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let policy_text = seatbelt_policy_arg(&args); assert!( @@ -1160,7 +1175,8 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -1196,7 +1212,8 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -1259,7 +1276,8 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { vulnerable_root.as_path(), /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let slash_tmp = PathBuf::from("/tmp") .canonicalize() diff --git a/codex-rs/sandboxing/src/windows.rs b/codex-rs/sandboxing/src/windows.rs new file mode 100644 index 000000000000..a57281145c25 --- /dev/null +++ b/codex-rs/sandboxing/src/windows.rs @@ -0,0 +1,382 @@ +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::protocol::WritableRoot; +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::SandboxType; +use crate::compatibility_sandbox_policy_for_permission_profile; + +/// Resolved filesystem overrides for the Windows sandbox backends. +/// +/// The elevated Windows backend consumes extra deny-read paths plus explicit +/// read and write roots during setup/refresh. The unelevated restricted-token +/// backend only consumes extra deny-write carveouts on top of the legacy +/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the +/// baseline helper roots that the elevated setup path needs to launch the +/// sandboxed command; split policies that opt into platform defaults carry +/// that explicitly with the override. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowsSandboxFilesystemOverrides { + pub read_roots_override: Option>, + pub read_roots_include_platform_defaults: bool, + pub write_roots_override: Option>, + pub additional_deny_read_paths: Vec, + pub additional_deny_write_paths: Vec, +} + +pub fn windows_sandbox_uses_elevated_backend( + sandbox_level: WindowsSandboxLevel, + proxy_enforced: bool, +) -> bool { + // Windows firewall enforcement is tied to the logon-user sandbox identities, so + // proxy-enforced sessions must use that backend even when the configured mode is + // the default restricted-token sandbox. + proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated) +} + +pub fn permission_profile_supports_windows_restricted_token_sandbox( + permission_profile: &PermissionProfile, +) -> bool { + match permission_profile { + PermissionProfile::Managed { file_system, .. } => { + !file_system.to_sandbox_policy().has_full_disk_write_access() + } + PermissionProfile::Disabled | PermissionProfile::External { .. } => false, + } +} + +pub fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + if windows_sandbox_level == WindowsSandboxLevel::Elevated { + resolve_windows_elevated_filesystem_overrides( + sandbox, + permission_profile, + sandbox_policy_cwd, + windows_sandbox_level == WindowsSandboxLevel::Elevated, + ) + .err() + } else { + resolve_windows_restricted_token_filesystem_overrides( + sandbox, + permission_profile, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() + } +} + +pub fn resolve_windows_restricted_token_filesystem_overrides( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken + || windows_sandbox_level == WindowsSandboxLevel::Elevated + { + return Ok(None); + } + + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if permission_profile_supports_windows_restricted_token_sandbox(permission_profile) + && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); + return Err(format!( + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", + file_system_sandbox_policy.kind, + )); + } + + // The restricted-token backend can still enforce split write restrictions, + // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs + // participate in read access checks. Read restrictions therefore require the + // elevated backend, even when the filesystem root remains readable. + if !windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd) { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + &file_system_sandbox_policy, + sandbox_policy_cwd, + )?; + if !additional_deny_read_paths.is_empty() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_override_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_override_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_override_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_override_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths.insert(normalize_windows_override_path( + read_only_subpath.as_path(), + )?); + } + } + } + + if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_override: None, + read_roots_include_platform_defaults: false, + write_roots_override: None, + additional_deny_read_paths, + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +pub fn resolve_windows_elevated_filesystem_overrides( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + use_windows_elevated_backend: bool, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken || !use_windows_elevated_backend { + return Ok(None); + } + + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); + return Err(format!( + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", + file_system_sandbox_policy.kind, + )); + } + + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + &file_system_sandbox_policy, + sandbox_policy_cwd, + )?; + + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + if has_reopened_writable_descendant(&split_writable_roots) { + return Err( + "windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_path(root.root.to_path_buf())) + .collect(); + let split_readable_roots: Vec = file_system_sandbox_policy + .get_readable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .map(&normalize_path) + .collect(); + let split_root_paths: Vec = split_writable_roots + .iter() + .map(|root| normalize_path(root.root.to_path_buf())) + .collect(); + let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); + + // `has_full_disk_read_access()` is intentionally false when deny-read + // entries exist. For Windows setup overrides, the important question is + // whether the baseline still reads from the filesystem root and only needs + // additional deny ACLs layered on top. + let split_has_root_read_access = + windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd); + let read_roots_override = if split_has_root_read_access { + None + } else { + Some(split_readable_roots) + }; + + let write_roots_override = if split_root_path_set == legacy_root_paths { + None + } else { + Some(split_root_paths) + }; + + let additional_deny_write_paths = if needs_direct_runtime_enforcement { + let mut deny_paths = BTreeSet::new(); + for writable_root in &split_writable_roots { + let writable_root_path = normalize_path(writable_root.root.to_path_buf()); + let legacy_root = legacy_writable_roots.iter().find(|candidate| { + normalize_path(candidate.root.to_path_buf()) == writable_root_path + }); + for read_only_subpath in &writable_root.read_only_subpaths { + let read_only_subpath_suffix = read_only_subpath + .as_path() + .strip_prefix(writable_root.root.as_path()) + .ok(); + let already_denied_by_legacy = legacy_root.is_some_and(|legacy_root| { + legacy_root.read_only_subpaths.iter().any(|candidate| { + candidate + .as_path() + .strip_prefix(legacy_root.root.as_path()) + .ok() + == read_only_subpath_suffix + }) + }); + if !already_denied_by_legacy { + deny_paths.insert(normalize_path(read_only_subpath.to_path_buf())); + } + } + } + deny_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()? + } else { + Vec::new() + }; + + if read_roots_override.is_none() + && write_roots_override.is_none() + && additional_deny_read_paths.is_empty() + && additional_deny_write_paths.is_empty() + { + return Ok(None); + } + + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_include_platform_defaults: read_roots_override.is_some() + && file_system_sandbox_policy.include_platform_defaults(), + read_roots_override, + write_roots_override, + additional_deny_read_paths, + additional_deny_write_paths, + })) +} + +fn normalize_windows_override_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) +} + +fn windows_policy_has_root_read_access( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + cwd: &AbsolutePathBuf, +) -> bool { + let Some(root) = cwd.as_path().ancestors().last() else { + return false; + }; + file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path()) +} + +fn permission_profile_display_name(permission_profile: &PermissionProfile) -> &'static str { + match permission_profile { + PermissionProfile::Managed { .. } => "Managed", + PermissionProfile::Disabled => "Disabled", + PermissionProfile::External { .. } => "External", + } +} + +fn has_reopened_writable_descendant(writable_roots: &[WritableRoot]) -> bool { + writable_roots.iter().any(|writable_root| { + writable_root + .read_only_subpaths + .iter() + .any(|read_only_subpath| { + writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) + }) + }) +} diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index 30526bd9d82c..df395fb44274 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -135,7 +135,7 @@ If MCP tools fail or no OpenAI docs resources are available: 5. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. 6. Keep SDK, tooling, IDE, plugin, shell, auth, and provider-environment migrations out of a model-and-prompt upgrade unless the user explicitly asks for them. 7. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. -8. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. +8. For general docs lookup, start with a compact, title-like search query of 2-6 essential terms. Do not turn the full user question into a keyword list. Fetch the best page and exact section needed, and answer with concise citations. ## Reference map 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 5eb2251a0efa..ec5476ae14e2 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 @@ -62,10 +62,31 @@ - `keywords` (`array` of `string`): Search/discovery tags. - `skills` (`string`): Relative path to skill directories/files. - `hooks` (`string`): Hook config path. -- `mcpServers` (`string`): MCP config path. +- `mcpServers` (`string` or `object`): MCP config path, or an object whose keys are MCP server names and whose values are MCP server config objects. - `apps` (`string`): App manifest path for plugin integrations. - `interface` (`object`): Interface/UX metadata block for plugin presentation. +`mcpServers` may be declared as a companion file path: + +```json +{ + "mcpServers": "./.mcp.json" +} +``` + +Or as an object directly in `plugin.json`: + +```json +{ + "mcpServers": { + "counter": { + "type": "http", + "url": "https://sample.example/counter/mcp" + } + } +} +``` + ### `interface` fields - `displayName` (`string`): User-facing title shown for the plugin. @@ -91,7 +112,7 @@ ### Path conventions and defaults - Path values should be relative and begin with `./`. -- `skills`, `hooks`, and `mcpServers` are supplemented on top of default component discovery; they do not replace defaults. +- `skills`, `hooks`, and string-valued `mcpServers` are supplemented on top of default component discovery; they do not replace defaults. - Custom path values must follow the plugin root convention and naming/namespacing rules. - This repo’s scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates. @@ -186,8 +207,9 @@ personal marketplace unless the caller explicitly requests a repo-local destinat present. - `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when present. -- `apps` and `mcpServers` should appear in `plugin.json` only when `.app.json` and `.mcp.json` - actually exist. +- `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 + `plugin.json`. - Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of generated manifests. - Run `scripts/validate_plugin.py ` before handing back a generated plugin. It adds one 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 6f49cb0feda7..5c6fae8e6332 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 @@ -126,18 +126,13 @@ def validate_manifest_shape( validate_optional_contract_path(manifest, "skills", "skills", errors) validate_optional_contract_path(manifest, "apps", ".app.json", errors) - validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors) + validate_manifest_mcp_servers(plugin_root, manifest, errors) if manifest.get("apps") is not None: validate_app_manifest( plugin_root / ".app.json", errors, ) - if manifest.get("mcpServers") is not None: - validate_mcp_manifest( - plugin_root / ".mcp.json", - errors, - ) validate_skill_manifests(plugin_root, errors) interface = require_object(manifest, "interface", errors) @@ -286,6 +281,32 @@ def validate_optional_contract_path( errors.append(f"plugin.json field `{key}` must resolve to `{expected}`") +def validate_manifest_mcp_servers( + plugin_root: Path, + manifest: dict[str, Any], + errors: list[str], +) -> None: + value = manifest.get("mcpServers") + if value is None: + return + if isinstance(value, str): + validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors) + validate_mcp_manifest( + plugin_root / ".mcp.json", + errors, + ) + return + if isinstance(value, dict): + validate_mcp_server_entries( + value, + "plugin.json field `mcpServers`", + "plugin.json field `mcpServers`", + errors, + ) + return + errors.append("plugin.json field `mcpServers` must be a string path or object") + + def normalize_contract_path(raw_path: str) -> str | None: path = Path(raw_path) if path.is_absolute(): @@ -326,14 +347,28 @@ def validate_mcp_manifest(path: Path, errors: list[str]) -> None: return reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors) servers = payload.get("mcpServers") + validate_mcp_server_entries( + servers, + "`.mcp.json`", + "`.mcp.json` field `mcpServers`", + errors, + ) + + +def validate_mcp_server_entries( + servers: Any, + source_label: str, + field_label: str, + errors: list[str], +) -> None: if not isinstance(servers, dict): - errors.append("`.mcp.json` field `mcpServers` must be an object") + errors.append(f"{field_label} must be an object") return for key, value in servers.items(): if not isinstance(key, str) or not key.strip(): - errors.append("`.mcp.json` server names must be non-empty strings") + errors.append(f"{source_label} server names must be non-empty strings") if not isinstance(value, dict): - errors.append(f"`.mcp.json` server `{key}` must be an object") + errors.append(f"{source_label} server `{key}` must be an object") def load_companion_json_object( diff --git a/codex-rs/state/migrations/0045_external_agent_config_imports.sql b/codex-rs/state/migrations/0045_external_agent_config_imports.sql new file mode 100644 index 000000000000..74ae0435f835 --- /dev/null +++ b/codex-rs/state/migrations/0045_external_agent_config_imports.sql @@ -0,0 +1,6 @@ +CREATE TABLE external_agent_config_imports ( + import_id TEXT PRIMARY KEY, + completed_at_ms INTEGER NOT NULL, + successes TEXT NOT NULL, + failures TEXT NOT NULL +); diff --git a/codex-rs/state/migrations/0046_threads_recency_at.sql b/codex-rs/state/migrations/0046_threads_recency_at.sql new file mode 100644 index 000000000000..ccbf79f05fab --- /dev/null +++ b/codex-rs/state/migrations/0046_threads_recency_at.sql @@ -0,0 +1,28 @@ +ALTER TABLE threads ADD COLUMN recency_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE threads ADD COLUMN recency_at_ms INTEGER NOT NULL DEFAULT 0; + +UPDATE threads +SET recency_at = updated_at, + recency_at_ms = updated_at_ms; + +-- Older binaries can open databases migrated by newer binaries. Seed recency +-- when one of those binaries inserts a thread without the new columns. +CREATE TRIGGER threads_recency_at_after_insert +AFTER INSERT ON threads +WHEN NEW.recency_at_ms = 0 +BEGIN + UPDATE threads + SET recency_at = NEW.updated_at, + recency_at_ms = COALESCE(NEW.updated_at_ms, NEW.updated_at * 1000) + WHERE id = NEW.id; +END; + +CREATE INDEX idx_threads_recency_at_ms + ON threads(recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_archived_cwd_recency_at_ms + ON threads(archived, cwd, recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_visible_recency_at_ms + ON threads(archived, recency_at_ms DESC, id DESC) + WHERE preview <> ''; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index ce2da4156de5..aa1dd04036b7 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -74,7 +74,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) { if metadata.cwd.as_os_str().is_empty() { - metadata.cwd = turn_ctx.cwd.clone(); + metadata.cwd = turn_ctx.cwd.clone().into_path_buf(); } metadata.model = Some(turn_ctx.model.clone()); metadata.reasoning_effort = turn_ctx.effort.clone(); @@ -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"), @@ -349,7 +350,12 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/parent/workspace"), + cwd: serde_json::from_value(serde_json::json!( + std::env::current_dir() + .expect("current directory") + .join("parent/workspace") + )) + .expect("absolute parent cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -363,6 +369,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -388,7 +395,12 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/workspace"), + cwd: serde_json::from_value(serde_json::json!( + std::env::current_dir() + .expect("current directory") + .join("workspace") + )) + .expect("absolute workspace cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -402,6 +414,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -419,12 +432,16 @@ mod tests { fn turn_context_sets_cwd_when_session_cwd_missing() { let mut metadata = metadata_for_test(); metadata.cwd = PathBuf::new(); + let fallback_cwd = std::env::current_dir() + .expect("current directory") + .join("fallback/workspace"); apply_rollout_item( &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/fallback/workspace"), + cwd: serde_json::from_value(serde_json::json!(&fallback_cwd)) + .expect("absolute fallback cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -438,6 +455,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -445,7 +463,7 @@ mod tests { "test-provider", ); - assert_eq!(metadata.cwd, PathBuf::from("/fallback/workspace")); + assert_eq!(metadata.cwd, fallback_cwd); } #[test] @@ -456,7 +474,12 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - cwd: PathBuf::from("/fallback/workspace"), + cwd: serde_json::from_value(serde_json::json!( + std::env::current_dir() + .expect("current directory") + .join("fallback/workspace") + )) + .expect("absolute fallback cwd"), workspace_roots: None, current_date: None, timezone: None, @@ -470,6 +493,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -490,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, @@ -525,6 +550,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/a.jsonl"), created_at, updated_at: created_at, + recency_at: created_at, source: "cli".to_string(), thread_source: None, agent_path: None, diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index a4315c81c788..ca6a34e472cb 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -56,6 +56,10 @@ pub use model::ThreadGoalStatus; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; +pub use runtime::ExternalAgentConfigImportDetailsRecord; +pub use runtime::ExternalAgentConfigImportFailureRecord; +pub use runtime::ExternalAgentConfigImportHistoryRecord; +pub use runtime::ExternalAgentConfigImportSuccessRecord; pub use runtime::GoalAccountingMode; pub use runtime::GoalAccountingOutcome; pub use runtime::GoalStore; diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 71d2afbc01dc..55de2a30777c 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, diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 0e12f17c45f2..187e1fb33d4c 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use sqlx::SqlitePool; use sqlx::migrate::Migrator; pub(crate) static STATE_MIGRATOR: Migrator = sqlx::migrate!("./migrations"); @@ -39,3 +40,49 @@ pub(crate) fn runtime_goals_migrator() -> Migrator { pub(crate) fn runtime_memories_migrator() -> Migrator { runtime_migrator(&MEMORIES_MIGRATOR) } + +pub(crate) async fn repair_legacy_recency_migration_version( + pool: &SqlitePool, + migrator: &Migrator, +) -> anyhow::Result<()> { + let Some(recency_migration) = migrator + .migrations + .iter() + .find(|migration| migration.version == 39) + else { + return Ok(()); + }; + let migrations_table_exists = sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'", + ) + .fetch_optional(pool) + .await? + .is_some(); + if !migrations_table_exists { + return Ok(()); + } + + sqlx::query( + r#" +UPDATE _sqlx_migrations +SET version = ?, description = ? +WHERE version = ? + AND checksum = ? + AND NOT EXISTS ( + SELECT 1 FROM _sqlx_migrations WHERE version = ? + ) + "#, + ) + .bind(recency_migration.version) + .bind(recency_migration.description.as_ref()) + .bind(38_i64) + .bind(recency_migration.checksum.as_ref()) + .bind(recency_migration.version) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +#[path = "migrations_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs new file mode 100644 index 000000000000..d11cc13ea1dd --- /dev/null +++ b/codex-rs/state/src/migrations_tests.rs @@ -0,0 +1,198 @@ +use std::borrow::Cow; + +use sqlx::Row; +use sqlx::migrate::Migration; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +use super::STATE_MIGRATOR; +use super::repair_legacy_recency_migration_version; + +fn migrator_through(version: i64) -> Migrator { + Migrator { + migrations: Cow::Owned( + STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version <= version) + .cloned() + .collect(), + ), + ignore_missing: STATE_MIGRATOR.ignore_missing, + locking: STATE_MIGRATOR.locking, + table_name: STATE_MIGRATOR.table_name.clone(), + create_schemas: STATE_MIGRATOR.create_schemas.clone(), + no_tx: STATE_MIGRATOR.no_tx, + } +} + +#[tokio::test] +async fn recency_migration_backfills_and_seeds_old_binary_inserts() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory database should open"); + migrator_through(/*version*/ 37) + .run(&pool) + .await + .expect("pre-recency migrations should apply"); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000001") + .bind("/tmp/first.jsonl") + .bind(1_700_000_000_i64) + .bind(1_700_000_100_i64) + .bind(1_700_000_000_123_i64) + .bind(1_700_000_100_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("legacy row should insert"); + + STATE_MIGRATOR + .run(&pool) + .await + .expect("recency migration should apply"); + + let backfilled = sqlx::query( + "SELECT updated_at, updated_at_ms, recency_at, recency_at_ms FROM threads WHERE id = ?", + ) + .bind("00000000-0000-0000-0000-000000000001") + .fetch_one(&pool) + .await + .expect("backfilled row should load"); + assert_eq!(backfilled.get::("recency_at"), 1_700_000_100); + assert_eq!(backfilled.get::("recency_at_ms"), 1_700_000_100_456); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000002") + .bind("/tmp/second.jsonl") + .bind(1_700_000_200_i64) + .bind(1_700_000_300_i64) + .bind(1_700_000_200_123_i64) + .bind(1_700_000_300_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("old-binary row should insert"); + + let seeded = sqlx::query("SELECT recency_at, recency_at_ms FROM threads WHERE id = ?") + .bind("00000000-0000-0000-0000-000000000002") + .fetch_one(&pool) + .await + .expect("old-binary row should load"); + assert_eq!(seeded.get::("recency_at"), 1_700_000_300); + assert_eq!(seeded.get::("recency_at_ms"), 1_700_000_300_456); +} + +#[tokio::test] +async fn repairs_recency_migration_that_was_applied_as_version_38() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory database should open"); + migrator_through(/*version*/ 37) + .run(&pool) + .await + .expect("pre-recency migrations should apply"); + + let recency_migration = STATE_MIGRATOR + .migrations + .iter() + .find(|migration| migration.version == 39) + .expect("recency migration should exist"); + let mut legacy_migrations = STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version <= 37) + .cloned() + .collect::>(); + legacy_migrations.push(Migration::new( + 38, + recency_migration.description.clone(), + recency_migration.migration_type, + recency_migration.sql.clone(), + recency_migration.no_tx, + )); + let legacy_recency_migrator = Migrator::with_migrations(legacy_migrations); + legacy_recency_migrator + .run(&pool) + .await + .expect("legacy recency migration should apply as version 38"); + + repair_legacy_recency_migration_version(&pool, &STATE_MIGRATOR) + .await + .expect("legacy migration history should be repaired"); + STATE_MIGRATOR + .run(&pool) + .await + .expect("current migrations should apply after repair"); + + let applied = sqlx::query( + "SELECT version, checksum FROM _sqlx_migrations WHERE version >= 38 ORDER BY version", + ) + .fetch_all(&pool) + .await + .expect("applied migrations should load") + .into_iter() + .map(|row| { + ( + row.get::("version"), + row.get::, _>("checksum"), + ) + }) + .collect::>(); + let expected = STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version >= 38) + .map(|migration| (migration.version, migration.checksum.to_vec())) + .collect::>(); + assert_eq!(applied, expected); +} diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index a30f7e479b84..c25c458986e3 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -18,6 +18,8 @@ pub enum SortKey { CreatedAt, /// Sort by the thread's last update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// Sort direction to use when listing threads. @@ -32,6 +34,8 @@ pub enum SortDirection { pub struct Anchor { /// The timestamp component of the anchor. pub ts: DateTime, + /// The thread ID component used to disambiguate equal recency timestamps. + pub id: Option, } /// A single page of thread metadata results. @@ -67,6 +71,8 @@ pub struct ThreadMetadata { pub created_at: DateTime, /// The last update timestamp. pub updated_at: DateTime, + /// The product recency timestamp. + pub recency_at: DateTime, /// The session source (stringified enum). pub source: String, /// Optional analytics source classification for this thread. @@ -120,6 +126,8 @@ pub struct ThreadMetadataBuilder { pub created_at: DateTime, /// The last update timestamp, if known. pub updated_at: Option>, + /// The product recency timestamp, if known. + pub recency_at: Option>, /// The session source. pub source: SessionSource, /// Optional analytics source classification for this thread. @@ -163,6 +171,7 @@ impl ThreadMetadataBuilder { rollout_path, created_at, updated_at: None, + recency_at: None, source, thread_source: None, agent_nickname: None, @@ -190,11 +199,16 @@ impl ThreadMetadataBuilder { .updated_at .map(canonicalize_datetime) .unwrap_or(created_at); + let recency_at = self + .recency_at + .map(canonicalize_datetime) + .unwrap_or(updated_at); ThreadMetadata { id: self.id, rollout_path: self.rollout_path.clone(), created_at, updated_at, + recency_at, source, thread_source: self.thread_source.clone(), agent_nickname: self.agent_nickname.clone(), @@ -340,6 +354,7 @@ pub(crate) struct ThreadRow { rollout_path: String, created_at: i64, updated_at: i64, + recency_at: i64, source: String, thread_source: Option, agent_nickname: Option, @@ -369,6 +384,7 @@ impl ThreadRow { rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, + recency_at: row.try_get("recency_at")?, source: row.try_get("source")?, thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, @@ -402,6 +418,7 @@ impl TryFrom for ThreadMetadata { rollout_path, created_at, updated_at, + recency_at, source, thread_source, agent_nickname, @@ -432,6 +449,7 @@ impl TryFrom for ThreadMetadata { rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, + recency_at: epoch_millis_to_datetime(recency_at)?, source, thread_source, agent_nickname, @@ -461,8 +479,12 @@ pub(crate) fn anchor_from_item(item: &ThreadMetadata, sort_key: SortKey) -> Opti let ts = match sort_key { SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, + SortKey::RecencyAt => item.recency_at, }; - Some(Anchor { ts }) + Some(Anchor { + ts, + id: (sort_key == SortKey::RecencyAt).then_some(item.id), + }) } pub(crate) fn datetime_to_epoch_millis(dt: DateTime) -> i64 { @@ -519,6 +541,7 @@ mod tests { rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, updated_at: 1_700_000_100, + recency_at: 1_700_000_100, source: "cli".to_string(), thread_source: None, agent_nickname: None, @@ -549,6 +572,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"), created_at: DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), + recency_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 552c9b92641f..4409a8ee665f 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -17,6 +17,7 @@ use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; use crate::ThreadsPage; use crate::apply_rollout_item; +use crate::migrations::repair_legacy_recency_migration_version; use crate::migrations::runtime_goals_migrator; use crate::migrations::runtime_logs_migrator; use crate::migrations::runtime_memories_migrator; @@ -59,6 +60,7 @@ use tracing::warn; mod agent_jobs; mod backfill; +mod external_agent_config_imports; mod goals; mod logs; mod memories; @@ -72,6 +74,10 @@ mod tool_router; mod tool_router_output_optimization; mod tool_router_tune; +pub use external_agent_config_imports::ExternalAgentConfigImportDetailsRecord; +pub use external_agent_config_imports::ExternalAgentConfigImportFailureRecord; +pub use external_agent_config_imports::ExternalAgentConfigImportHistoryRecord; +pub use external_agent_config_imports::ExternalAgentConfigImportSuccessRecord; pub use goals::GoalAccountingMode; pub use goals::GoalAccountingOutcome; pub use goals::GoalStore; @@ -206,6 +212,7 @@ pub struct StateRuntime { thread_goals: GoalStore, memories: MemoryStore, thread_updated_at_millis: Arc, + thread_recency_at_millis: Arc, } impl StateRuntime { @@ -308,32 +315,36 @@ impl StateRuntime { return Err(err); } let started = Instant::now(); - let thread_updated_at_millis_result: anyhow::Result> = - sqlx::query_scalar("SELECT MAX(threads.updated_at_ms) FROM threads") - .fetch_one(pool.as_ref()) - .await - .map_err(anyhow::Error::from); + let thread_timestamp_millis_result: anyhow::Result<(Option, Option)> = + sqlx::query_as( + "SELECT MAX(threads.updated_at_ms), MAX(threads.recency_at_ms) FROM threads", + ) + .fetch_one(pool.as_ref()) + .await + .map_err(anyhow::Error::from); crate::telemetry::record_init_result( telemetry_override, DbKind::State, "post_init_query", started.elapsed(), - &thread_updated_at_millis_result, + &thread_timestamp_millis_result, ); - let thread_updated_at_millis = match thread_updated_at_millis_result { - Ok(value) => value, - Err(err) => { - close_sqlite_pools(&[ - pool.as_ref(), - logs_pool.as_ref(), - goals_pool.as_ref(), - memories_pool.as_ref(), - ]) - .await; - return Err(err); - } - }; + let (thread_updated_at_millis, thread_recency_at_millis) = + match thread_timestamp_millis_result { + Ok(value) => value, + Err(err) => { + close_sqlite_pools(&[ + pool.as_ref(), + logs_pool.as_ref(), + goals_pool.as_ref(), + memories_pool.as_ref(), + ]) + .await; + return Err(err); + } + }; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); + let thread_recency_at_millis = thread_recency_at_millis.unwrap_or(0); let runtime = Arc::new(Self { thread_goals: GoalStore::new(Arc::clone(&goals_pool)), memories: MemoryStore::new(Arc::clone(&memories_pool), Arc::clone(&pool)), @@ -342,6 +353,7 @@ impl StateRuntime { codex_home, default_provider, thread_updated_at_millis: Arc::new(AtomicI64::new(thread_updated_at_millis)), + thread_recency_at_millis: Arc::new(AtomicI64::new(thread_recency_at_millis)), }); if let Err(err) = runtime.run_logs_startup_maintenance().await { warn!( @@ -466,7 +478,13 @@ async fn open_sqlite( let pool = pool_result .map_err(|source| recovery::RuntimeDbInitError::new(spec.label, "open", path, source))?; let started = Instant::now(); - let migrate_result = migrator.run(&pool).await.map_err(anyhow::Error::from); + let migrate_result = async { + if matches!(spec.kind, DbKind::State) { + repair_legacy_recency_migration_version(&pool, migrator).await?; + } + migrator.run(&pool).await.map_err(anyhow::Error::from) + } + .await; crate::telemetry::record_init_result( telemetry_override, spec.kind, diff --git a/codex-rs/state/src/runtime/external_agent_config_imports.rs b/codex-rs/state/src/runtime/external_agent_config_imports.rs new file mode 100644 index 000000000000..4fbbe39efcd9 --- /dev/null +++ b/codex-rs/state/src/runtime/external_agent_config_imports.rs @@ -0,0 +1,136 @@ +use super::StateRuntime; +use crate::model::datetime_to_epoch_millis; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use sqlx::Row; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalAgentConfigImportSuccessRecord { + pub item_type: String, + pub cwd: Option, + pub source: Option, + pub target: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalAgentConfigImportFailureRecord { + pub item_type: String, + pub error_type: Option, + pub failure_stage: String, + pub message: String, + pub cwd: Option, + pub source: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalAgentConfigImportDetailsRecord { + pub successes: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalAgentConfigImportHistoryRecord { + pub import_id: String, + pub completed_at_ms: i64, + pub successes: Vec, + pub failures: Vec, +} + +impl StateRuntime { + pub async fn record_external_agent_config_import_completed( + &self, + import_id: &str, + successes: &[ExternalAgentConfigImportSuccessRecord], + failures: &[ExternalAgentConfigImportFailureRecord], + ) -> anyhow::Result<()> { + sqlx::query( + r#" +INSERT INTO external_agent_config_imports ( + import_id, + completed_at_ms, + successes, + failures +) VALUES (?, ?, ?, ?) +ON CONFLICT(import_id) DO UPDATE SET + completed_at_ms = excluded.completed_at_ms, + successes = excluded.successes, + failures = excluded.failures +"#, + ) + .bind(import_id) + .bind(datetime_to_epoch_millis(Utc::now())) + .bind(serde_json::to_string(successes)?) + .bind(serde_json::to_string(failures)?) + .execute(self.pool.as_ref()) + .await?; + + Ok(()) + } + + pub async fn external_agent_config_import_details_record( + &self, + import_id: &str, + ) -> anyhow::Result> { + let row = sqlx::query( + r#" +SELECT + successes, + failures +FROM external_agent_config_imports +WHERE import_id = ? +"#, + ) + .bind(import_id) + .fetch_optional(self.pool.as_ref()) + .await?; + + row.map(|row| { + let successes: String = row.try_get("successes")?; + let failures: String = row.try_get("failures")?; + Ok(ExternalAgentConfigImportDetailsRecord { + successes: serde_json::from_str(&successes)?, + failures: serde_json::from_str(&failures)?, + }) + }) + .transpose() + } + + pub async fn external_agent_config_import_history_records( + &self, + ) -> anyhow::Result> { + let rows = sqlx::query( + r#" +SELECT + import_id, + completed_at_ms, + successes, + failures +FROM external_agent_config_imports +ORDER BY completed_at_ms DESC, import_id ASC +"#, + ) + .fetch_all(self.pool.as_ref()) + .await?; + + rows.into_iter() + .map(|row| { + let import_id: String = row.try_get("import_id")?; + let completed_at_ms: i64 = row.try_get("completed_at_ms")?; + let successes: String = row.try_get("successes")?; + let failures: String = row.try_get("failures")?; + Ok(ExternalAgentConfigImportHistoryRecord { + import_id, + completed_at_ms, + successes: serde_json::from_str(&successes)?, + failures: serde_json::from_str(&failures)?, + }) + }) + .collect() + } +} + +#[cfg(test)] +#[path = "external_agent_config_imports_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/runtime/external_agent_config_imports_tests.rs b/codex-rs/state/src/runtime/external_agent_config_imports_tests.rs new file mode 100644 index 000000000000..6d77894d43e9 --- /dev/null +++ b/codex-rs/state/src/runtime/external_agent_config_imports_tests.rs @@ -0,0 +1,145 @@ +use super::*; +use crate::runtime::test_support::unique_temp_dir; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn records_completion_by_import_id() -> anyhow::Result<()> { + let runtime = StateRuntime::init(unique_temp_dir(), "test-provider".to_string()).await?; + + runtime + .record_external_agent_config_import_completed( + "import-1", + &[ExternalAgentConfigImportSuccessRecord { + item_type: "CONFIG".to_string(), + cwd: None, + source: Some("settings.json".to_string()), + target: Some("config.toml".to_string()), + }], + &[], + ) + .await?; + runtime + .record_external_agent_config_import_completed( + "import-1", + &[ + ExternalAgentConfigImportSuccessRecord { + item_type: "CONFIG".to_string(), + cwd: None, + source: Some("settings.json".to_string()), + target: Some("config.toml".to_string()), + }, + ExternalAgentConfigImportSuccessRecord { + item_type: "MCP_SERVER_CONFIG".to_string(), + cwd: None, + source: Some("github".to_string()), + target: Some("github".to_string()), + }, + ], + &[ExternalAgentConfigImportFailureRecord { + item_type: "MCP_SERVER_CONFIG".to_string(), + error_type: None, + failure_stage: "import".to_string(), + message: "failed".to_string(), + cwd: None, + source: Some("broken".to_string()), + }], + ) + .await?; + + assert_eq!( + runtime + .external_agent_config_import_details_record("import-1") + .await?, + Some(ExternalAgentConfigImportDetailsRecord { + successes: vec![ + ExternalAgentConfigImportSuccessRecord { + item_type: "CONFIG".to_string(), + cwd: None, + source: Some("settings.json".to_string()), + target: Some("config.toml".to_string()), + }, + ExternalAgentConfigImportSuccessRecord { + item_type: "MCP_SERVER_CONFIG".to_string(), + cwd: None, + source: Some("github".to_string()), + target: Some("github".to_string()), + } + ], + failures: vec![ExternalAgentConfigImportFailureRecord { + item_type: "MCP_SERVER_CONFIG".to_string(), + error_type: None, + failure_stage: "import".to_string(), + message: "failed".to_string(), + cwd: None, + source: Some("broken".to_string()), + }], + }) + ); + assert_eq!( + runtime + .external_agent_config_import_history_records() + .await? + .into_iter() + .map(|record| ( + record.import_id, + record.successes, + record.failures, + record.completed_at_ms > 0 + )) + .collect::>(), + vec![( + "import-1".to_string(), + vec![ + ExternalAgentConfigImportSuccessRecord { + item_type: "CONFIG".to_string(), + cwd: None, + source: Some("settings.json".to_string()), + target: Some("config.toml".to_string()), + }, + ExternalAgentConfigImportSuccessRecord { + item_type: "MCP_SERVER_CONFIG".to_string(), + cwd: None, + source: Some("github".to_string()), + target: Some("github".to_string()), + } + ], + vec![ExternalAgentConfigImportFailureRecord { + item_type: "MCP_SERVER_CONFIG".to_string(), + error_type: None, + failure_stage: "import".to_string(), + message: "failed".to_string(), + cwd: None, + source: Some("broken".to_string()), + }], + true + )] + ); + + Ok(()) +} + +#[tokio::test] +async fn reads_all_history_records() -> anyhow::Result<()> { + let runtime = StateRuntime::init(unique_temp_dir(), "test-provider".to_string()).await?; + + runtime + .record_external_agent_config_import_completed("import-1", &[], &[]) + .await?; + runtime + .record_external_agent_config_import_completed("import-2", &[], &[]) + .await?; + + let mut records = runtime + .external_agent_config_import_history_records() + .await?; + records.sort_by(|left, right| left.import_id.cmp(&right.import_id)); + assert_eq!( + records + .into_iter() + .map(|record| record.import_id) + .collect::>(), + vec!["import-1".to_string(), "import-2".to_string()] + ); + + Ok(()) +} diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 895c254350f7..9b2c337f5fc4 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -175,6 +175,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_path, @@ -545,6 +546,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 848a1933352b..c029a0ee2db6 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -47,6 +47,7 @@ pub(super) fn test_thread_metadata( rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")), created_at: now, updated_at: now, + recency_at: now, source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 6cebf27e874a..a448dce56bcd 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1,6 +1,7 @@ use super::*; use crate::SortDirection; use codex_protocol::protocol::SessionSource; +use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; impl StateRuntime { @@ -12,6 +13,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -498,6 +500,7 @@ ON CONFLICT(child_thread_id) DO NOTHING metadata: &crate::ThreadMetadata, ) -> anyhow::Result { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); let result = sqlx::query( r#" @@ -506,8 +509,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -530,7 +535,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -538,8 +543,10 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -621,6 +628,32 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } + pub async fn touch_thread_recency_at( + &self, + thread_id: ThreadId, + recency_at: DateTime, + ) -> anyhow::Result { + let recency_at = self.allocate_thread_recency_at(recency_at)?; + let recency_at_seconds = datetime_to_epoch_seconds(recency_at); + let recency_at_millis = datetime_to_epoch_millis(recency_at); + let result = sqlx::query( + r#" +UPDATE threads +SET + recency_at = MAX(?, MAX(?, recency_at_ms + 1) / 1000), + recency_at_ms = MAX(?, recency_at_ms + 1) +WHERE id = ? + "#, + ) + .bind(recency_at_seconds) + .bind(recency_at_millis) + .bind(recency_at_millis) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + /// Allocate a persisted `updated_at` value for thread-list cursor ordering. /// /// We keep a process-local high-water mark so hot rollout writes can get unique, @@ -631,42 +664,56 @@ ON CONFLICT(id) DO NOTHING &self, updated_at: DateTime, ) -> anyhow::Result> { - let candidate = datetime_to_epoch_millis(updated_at); - let allocated = loop { - let current = self.thread_updated_at_millis.load(Ordering::Relaxed); - - // New wall-clock time: advance the process-local high-water mark and use it as-is. - if candidate > current { - if self - .thread_updated_at_millis - .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break candidate; - } - continue; - } + allocate_thread_timestamp(self.thread_updated_at_millis.as_ref(), updated_at) + } - // Older timestamps come from backfill/repair paths that preserve rollout mtimes. - // Do not drag historical rows forward just because this process has seen newer writes. - if candidate.saturating_add(1000) <= current { - break candidate; - } + fn allocate_thread_recency_at( + &self, + recency_at: DateTime, + ) -> anyhow::Result> { + allocate_thread_timestamp(self.thread_recency_at_millis.as_ref(), recency_at) + } +} - // Same hot one-second bucket as the current high-water mark. Allocate the next - // millisecond so updated_at remains unique and cursor-orderable inside the process. - let bumped = current.saturating_add(1); - if self - .thread_updated_at_millis - .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) +fn allocate_thread_timestamp( + high_water_mark: &AtomicI64, + timestamp: DateTime, +) -> anyhow::Result> { + let candidate = datetime_to_epoch_millis(timestamp); + let allocated = loop { + let current = high_water_mark.load(Ordering::Relaxed); + + // New wall-clock time: advance the process-local high-water mark and use it as-is. + if candidate > current { + if high_water_mark + .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { - break bumped; + break candidate; } - }; - epoch_millis_to_datetime(allocated) - } + continue; + } + + // Older timestamps come from backfill/repair paths that preserve rollout mtimes. + // Do not drag historical rows forward just because this process has seen newer writes. + if candidate.saturating_add(1000) <= current { + break candidate; + } + + // Same hot one-second bucket as the current high-water mark. Allocate the next + // millisecond so the timestamp remains unique and cursor-orderable inside the process. + let bumped = current.saturating_add(1); + if high_water_mark + .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break bumped; + } + }; + epoch_millis_to_datetime(allocated) +} +impl StateRuntime { pub async fn update_thread_git_info( &self, thread_id: ThreadId, @@ -702,6 +749,7 @@ WHERE id = ? creation_memory_mode: Option<&str>, ) -> anyhow::Result<()> { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let insert_recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); // Backfill/reconcile callers merge existing git info before upserting, but that // read/modify/write is not atomic. Preserve non-null SQLite git fields here so @@ -713,8 +761,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -737,13 +787,15 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, updated_at = excluded.updated_at, + recency_at = threads.recency_at, created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms, + recency_at_ms = threads.recency_at_ms, source = excluded.source, thread_source = excluded.thread_source, agent_nickname = excluded.agent_nickname, @@ -771,8 +823,10 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(insert_recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(insert_recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -1079,6 +1133,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -1197,6 +1252,7 @@ pub(super) fn push_thread_filters<'a>( let column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let operator = match sort_direction { SortDirection::Asc => ">", @@ -1208,6 +1264,19 @@ pub(super) fn push_thread_filters<'a>( builder.push(operator); builder.push(" "); builder.push_bind(anchor_ts); + if sort_key == SortKey::RecencyAt + && let Some(anchor_id) = anchor.id + { + builder.push(" OR ("); + builder.push(column); + builder.push(" = "); + builder.push_bind(anchor_ts); + builder.push(" AND threads.id "); + builder.push(operator); + builder.push(" "); + builder.push_bind(anchor_id.to_string()); + builder.push(")"); + } builder.push(")"); } } @@ -1232,6 +1301,7 @@ pub(super) fn push_thread_order_and_limit( let order_column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let order_direction = match sort_direction { SortDirection::Asc => "ASC", @@ -1247,6 +1317,10 @@ pub(super) fn push_thread_order_and_limit( builder.push(order_column); builder.push(" "); builder.push(order_direction); + if sort_key == SortKey::RecencyAt { + builder.push(", threads.id "); + builder.push(order_direction); + } builder.push(" LIMIT "); builder.push_bind(limit as i64); } @@ -1521,6 +1595,7 @@ mod tests { let anchor = Anchor { ts: older_updated_at, + id: None, }; let model_providers = ["test-provider".to_string()]; let page = runtime @@ -1547,6 +1622,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_200_000) .expect("valid timestamp"), + id: None, }) ); @@ -1631,6 +1707,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_300_000) .expect("valid timestamp"), + id: None, }) ); @@ -1693,6 +1770,7 @@ mod tests { ]; let anchor = Anchor { ts: DateTime::::from_timestamp(1_700_000_000, 0).expect("valid timestamp"), + id: None, }; for (sort_key, visible_index, cwd_index) in [ ( @@ -1705,6 +1783,11 @@ mod tests { "idx_threads_visible_updated_at_ms", "idx_threads_archived_cwd_updated_at_ms", ), + ( + SortKey::RecencyAt, + "idx_threads_visible_recency_at_ms", + "idx_threads_archived_cwd_recency_at_ms", + ), ] { for (cwd_filters, anchor, expected_index, expect_temp_sort) in [ (None, None, visible_index, false), @@ -1874,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, @@ -1935,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, @@ -2279,6 +2364,186 @@ mod tests { assert_eq!(persisted.preview.as_deref(), Some("first-user-message")); } + #[tokio::test] + async fn touch_thread_recency_at_is_monotonic_and_survives_stale_upsert() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + let original_recency_at = metadata.recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let touched_at = + DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp"); + assert!( + runtime + .touch_thread_recency_at(thread_id, touched_at) + .await + .expect("touch should succeed") + ); + + metadata.updated_at = + DateTime::::from_timestamp_millis(1_700_001_222_456).expect("timestamp"); + metadata.title = "updated metadata".to_string(); + assert_eq!(metadata.recency_at, original_recency_at); + runtime + .upsert_thread(&metadata) + .await + .expect("stale metadata upsert should succeed"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.recency_at, touched_at); + assert_eq!(persisted.updated_at, metadata.updated_at); + assert_eq!(persisted.title, "updated metadata"); + + assert!( + runtime + .touch_thread_recency_at(thread_id, original_recency_at) + .await + .expect("older touch should succeed") + ); + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!( + datetime_to_epoch_millis(persisted.recency_at), + datetime_to_epoch_millis(touched_at) + 1 + ); + } + + #[tokio::test] + async fn list_threads_orders_and_pages_by_recency_at() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let first_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000793").expect("valid thread id"); + let second_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000794").expect("valid thread id"); + let third_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000795").expect("valid thread id"); + let recency_at = + DateTime::::from_timestamp_millis(1_700_002_000_456).expect("timestamp"); + + for thread_id in [first_id, second_id, third_id] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.recency_at = recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + } + sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ?") + .bind(datetime_to_epoch_seconds(recency_at)) + .bind(datetime_to_epoch_millis(recency_at)) + .execute(runtime.pool.as_ref()) + .await + .expect("recency timestamps should match"); + + let first_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: None, + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("list should succeed"); + assert_eq!( + first_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![third_id] + ); + assert_eq!( + first_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(third_id), + }) + ); + + let second_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: first_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("second list should succeed"); + assert_eq!( + second_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![second_id] + ); + assert_eq!( + second_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(second_id), + }) + ); + + let third_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: second_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("third list should succeed"); + assert_eq!( + third_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![first_id] + ); + assert_eq!(third_page.next_anchor, None); + } + #[tokio::test] async fn thread_updated_at_uses_unique_epoch_millis_and_reads_legacy_seconds() { let codex_home = unique_temp_dir(); @@ -2295,8 +2560,10 @@ mod tests { DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp millis"); let mut first = test_thread_metadata(&codex_home, first_id, codex_home.clone()); first.updated_at = updated_at; + first.recency_at = updated_at; let mut second = test_thread_metadata(&codex_home, second_id, codex_home.clone()); second.updated_at = updated_at; + second.recency_at = updated_at; runtime .upsert_thread(&first) @@ -2325,6 +2592,14 @@ mod tests { datetime_to_epoch_millis(second.updated_at), 1_700_001_111_124 ); + assert_eq!( + datetime_to_epoch_millis(first.recency_at), + 1_700_001_111_123 + ); + assert_eq!( + datetime_to_epoch_millis(second.recency_at), + 1_700_001_111_124 + ); let second_row: (i64, i64, Option, Option) = sqlx::query_as( "SELECT created_at, updated_at, created_at_ms, updated_at_ms FROM threads WHERE id = ?", ) diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index e0d2fcb29365..12e101a67500 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -138,6 +138,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { state_db, installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let NewThread { @@ -198,6 +199,8 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R include_apps_instructions: false, include_collaboration_mode_instructions: false, include_skill_instructions: false, + orchestrator_skills_enabled: false, + orchestrator_mcp_enabled: false, include_environment_context: false, compact_prompt: None, notify: None, @@ -263,6 +266,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R response_style: ResponseStyle::Normal, artifact_style: ArtifactStyle::Normal, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + respect_system_proxy: false, apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, @@ -284,6 +288,9 @@ 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(), suppress_unstable_features_warning: false, active_project: ProjectConfig { trust_level: None }, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index d02b78d237d8..a93e3644c04e 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -79,7 +79,7 @@ mod tests { let items_err = store .list_items(ListItemsParams { thread_id, - turn_id: "turn_1".to_string(), + turn_id: None, include_archived: true, cursor: None, page_size: 10, @@ -110,6 +110,7 @@ mod tests { ] { store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, @@ -231,6 +232,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, @@ -535,6 +537,9 @@ fn stored_thread_from_state( updated_at: metadata .and_then(|metadata| metadata.updated_at) .unwrap_or_else(Utc::now), + recency_at: metadata + .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) + .unwrap_or_else(Utc::now), archived_at: None, cwd: metadata .and_then(|metadata| metadata.cwd.clone()) diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index 88d347f57ba9..6b9480751198 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -133,6 +133,11 @@ impl LiveThread { }) } + #[tracing::instrument( + level = "trace", + skip_all, + fields(item_count = items.len()) + )] pub async fn append_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> { let canonical_items = persisted_rollout_items(items); if items.is_empty() { diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index bbe5c3de516a..eb4a48b68012 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -174,5 +174,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, archived_path); assert!(updated.archived_at.is_some()); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 05924881d54c..51a96b08155e 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), ) .await diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 9e352942e817..fd43244b3b65 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -106,6 +106,7 @@ pub(super) fn stored_thread_from_rollout_item( .or_else(|| thread_id_from_rollout_path(item.path.as_path()))?; let created_at = parse_rfc3339(item.created_at.as_deref()).unwrap_or_else(Utc::now); let updated_at = parse_rfc3339(item.updated_at.as_deref()).unwrap_or(created_at); + let recency_at = parse_rfc3339(item.recency_at.as_deref()).unwrap_or(updated_at); let archived_at = archived.then_some(updated_at); let git_info = git_info_from_parts( item.git_sha.clone(), @@ -136,6 +137,7 @@ pub(super) fn stored_thread_from_rollout_item( reasoning_effort: None, created_at, updated_at, + recency_at, archived_at, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index fc3b5034ad33..03ae163afea9 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -34,6 +34,7 @@ pub(super) async fn list_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, diff --git a/codex-rs/thread-store/src/local/live_writer.rs b/codex-rs/thread-store/src/local/live_writer.rs index 61c15a43d9a7..24c31869f141 100644 --- a/codex-rs/thread-store/src/local/live_writer.rs +++ b/codex-rs/thread-store/src/local/live_writer.rs @@ -74,6 +74,11 @@ pub(super) async fn resume_thread( store.insert_live_recorder(params.thread_id, recorder).await } +#[tracing::instrument( + level = "trace", + skip_all, + fields(item_count = params.items.len()) +)] pub(super) async fn append_items( store: &LocalThreadStore, params: AppendThreadItemsParams, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index dd49fd2a592a..b96e05cfd5bf 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -314,10 +314,16 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; + use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::MessagePhase; + use codex_protocol::models::ResponseItem; + use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use tempfile::TempDir; @@ -449,6 +455,93 @@ mod tests { assert_eq!(metadata.title, "observed append"); } + #[tokio::test] + async fn live_thread_output_advances_updated_at_but_not_recency_at() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store, create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("start thread")]) + .await + .expect("append initial user message"); + live_thread.flush().await.expect("flush thread"); + let before_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + live_thread + .append_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .await + .expect("append turn start"); + live_thread.flush().await.expect("flush thread"); + let after_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert!(after_turn_start.recency_at > before_turn_start.recency_at); + + live_thread + .append_items(&[ + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "commentary".to_string(), + phase: Some(MessagePhase::Commentary), + memory_citation: None, + })), + RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + id: None, + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("tool output".to_string()), + internal_chat_message_metadata_passthrough: None, + }), + RolloutItem::EventMsg(EventMsg::TokenCount( + codex_protocol::protocol::TokenCountEvent { + info: None, + rate_limits: None, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]) + .await + .expect("append post-start items"); + live_thread.flush().await.expect("flush thread"); + let completed = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + assert!(completed.updated_at > after_turn_start.updated_at); + assert_eq!(completed.recency_at, after_turn_start.recency_at); + } + #[tokio::test] async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() { let home = TempDir::new().expect("temp dir"); @@ -1030,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 4778dd69d9c4..1271e4fcf5a2 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -55,6 +55,7 @@ pub(super) async fn read_thread( && (params.include_archived || rollout_thread.archived_at.is_none()) && !rollout_thread.preview.is_empty() { + rollout_thread.recency_at = thread.recency_at; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -115,6 +116,7 @@ pub(super) async fn read_thread_by_rollout_path( }); } if let Some(metadata) = read_sqlite_metadata(store, thread.thread_id).await { + thread.recency_at = metadata.recency_at; let existing_git_info = thread.git_info.take(); let (fallback_sha, fallback_branch, fallback_origin_url) = match existing_git_info { Some(info) => ( @@ -340,6 +342,7 @@ async fn stored_thread_from_sqlite_metadata( reasoning_effort: metadata.reasoning_effort, created_at: metadata.created_at, updated_at: metadata.updated_at, + recency_at: metadata.recency_at, archived_at: metadata.archived_at, cwd: metadata.cwd, cli_version: metadata.cli_version, @@ -406,6 +409,7 @@ fn stored_thread_from_meta_line( reasoning_effort: None, created_at, updated_at, + recency_at: updated_at, archived_at: archived.then_some(updated_at), cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, @@ -547,6 +551,10 @@ mod tests { ); builder.model_provider = Some(config.default_model_provider_id.clone()); builder.git_branch = Some("sqlite-branch".to_string()); + let recency_at = chrono::DateTime::parse_from_rfc3339("2026-01-03T12:00:00Z") + .expect("timestamp should parse") + .with_timezone(&Utc); + builder.recency_at = Some(recency_at); runtime .upsert_thread(&builder.build(config.default_model_provider_id.as_str())) .await @@ -562,6 +570,7 @@ mod tests { .expect("read thread by rollout path"); let git_info = thread.git_info.expect("git info should be present"); + assert_eq!(thread.recency_at, recency_at); assert_eq!(git_info.branch.as_deref(), Some("sqlite-branch")); assert_eq!( git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), @@ -812,6 +821,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, @@ -917,6 +927,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(), @@ -1082,6 +1093,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/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index 1b7f18a32ff2..fc2ddd6e715c 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -23,6 +23,10 @@ use crate::ThreadSortKey; use crate::ThreadStoreError; use crate::ThreadStoreResult; +#[cfg(test)] +#[path = "search_threads_tests.rs"] +mod tests; + struct ThreadSearchItem { item: codex_rollout::ThreadItem, snippet: String, @@ -50,6 +54,7 @@ pub(super) async fn search_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, @@ -179,8 +184,17 @@ fn cursor_from_thread_search_item( .updated_at .as_deref() .or(item.item.created_at.as_deref())?, + ThreadSortKey::RecencyAt => item + .item + .recency_at + .as_deref() + .or(item.item.updated_at.as_deref()) + .or(item.item.created_at.as_deref())?, }; - parse_cursor(timestamp) + match sort_key { + ThreadSortKey::RecencyAt => parse_cursor(&format!("{timestamp}|{}", item.item.thread_id?)), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => parse_cursor(timestamp), + } } async fn set_thread_search_result_names( diff --git a/codex-rs/thread-store/src/local/search_threads_tests.rs b/codex-rs/thread-store/src/local/search_threads_tests.rs new file mode 100644 index 000000000000..bfcf76827671 --- /dev/null +++ b/codex-rs/thread-store/src/local/search_threads_tests.rs @@ -0,0 +1,29 @@ +use codex_protocol::ThreadId; +use codex_rollout::ThreadItem; +use pretty_assertions::assert_eq; + +use super::ThreadSearchItem; +use super::cursor_from_thread_search_item; +use crate::ThreadSortKey; + +#[test] +fn recency_cursor_includes_thread_id_tie_breaker() { + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread ID should parse"); + let item = ThreadSearchItem { + item: ThreadItem { + thread_id: Some(thread_id), + recency_at: Some("2026-01-27T12:34:56Z".to_string()), + ..Default::default() + }, + snippet: String::new(), + }; + + let cursor = cursor_from_thread_search_item(&item, ThreadSortKey::RecencyAt) + .expect("cursor should build"); + + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"2026-01-27T12:34:56Z|{thread_id}\"") + ); +} diff --git a/codex-rs/thread-store/src/local/test_support.rs b/codex-rs/thread-store/src/local/test_support.rs index 98321880ffe9..10a55c4fd6cc 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/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index ad41db69acb1..ee25b4342465 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -196,5 +196,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, restored_path); assert_eq!(updated.archived_at, None); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 820cb68918f7..d1c76ad327a6 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -204,6 +204,7 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read thread metadata for {thread_id}: {err}"), })?; + let advance_recency_at = patch.advance_recency_at; if existing.is_none() && rollout_path.is_none() { let resolved = resolve_rollout_path(store, thread_id, include_archived).await?; rollout_path_archived = resolved.archived; @@ -260,6 +261,11 @@ async fn apply_metadata_update( if let Some(updated_at) = patch.updated_at { metadata.updated_at = updated_at; } + if existing.is_none() + && let Some(recency_at) = advance_recency_at + { + metadata.recency_at = recency_at; + } if let Some(source) = patch.source { metadata.source = enum_to_string(&source); } @@ -310,6 +316,18 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; + if existing.is_some() + && let Some(recency_at) = advance_recency_at + { + state_db + .touch_thread_recency_at(thread_id, recency_at) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!( + "failed to advance thread recency_at for {thread_id}: {err}" + ), + })?; + } if let Some(memory_mode) = patch.memory_mode { state_db .set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode)) diff --git a/codex-rs/thread-store/src/store.rs b/codex-rs/thread-store/src/store.rs index ef02e85d508e..65ad1ce8a887 100644 --- a/codex-rs/thread-store/src/store.rs +++ b/codex-rs/thread-store/src/store.rs @@ -102,7 +102,7 @@ pub trait ThreadStore: Any + Send + Sync { }) } - /// Lists persisted items within a stored turn. + /// Lists persisted items within a stored thread, optionally filtered to a turn. fn list_items(&self, _params: ListItemsParams) -> ThreadStoreFuture<'_, ItemPage> { Box::pin(async { Err(ThreadStoreError::Unsupported { diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 8eb23e3632c2..7940f1c80f41 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -154,11 +154,17 @@ impl ThreadMetadataSync { let affects_metadata = items .iter() .any(codex_state::rollout_item_affects_thread_metadata); - let update = if affects_metadata { + let advances_recency = items + .iter() + .any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_)))); + let mut update = if affects_metadata { self.observe_items(items)? } else { thread_updated_at_touch() }; + if advances_recency { + update.advance_recency_at = Some(Utc::now()); + } self.merge_pending_update(Some(update)); if !affects_metadata && !self @@ -227,9 +233,9 @@ impl ThreadMetadataSync { } } RolloutItem::TurnContext(turn_ctx) => { - if !self.cwd_seen && !turn_ctx.cwd.as_os_str().is_empty() { + if !self.cwd_seen { self.cwd_seen = true; - update.cwd = Some(turn_ctx.cwd.clone()); + update.cwd = Some(turn_ctx.cwd.clone().into_path_buf()); } update.model = Some(turn_ctx.model.clone()); update.reasoning_effort = turn_ctx.effort.clone(); @@ -347,6 +353,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.model.is_some() || update.reasoning_effort.is_some() || update.created_at.is_some() + || update.advance_recency_at.is_some() || update.source.is_some() || update.thread_source.is_some() || update.agent_nickname.is_some() @@ -379,6 +386,7 @@ mod tests { use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; @@ -475,6 +483,9 @@ mod tests { let item = RolloutItem::Compacted(CompactedItem { message: "compacted".to_string(), replacement_history: None, + window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }); @@ -495,6 +506,27 @@ mod tests { ); } + #[test] + fn turn_start_advances_recency_at_without_changing_updated_at_behavior() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + + let update = sync + .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .expect("turn start metadata update"); + + assert!(update.patch.updated_at.is_some()); + assert!(update.patch.advance_recency_at.is_some()); + } + #[test] fn resume_history_waits_for_append_before_flushing_metadata() { let thread_id = ThreadId::new(); @@ -547,6 +579,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 bb8bf2f6dcc4..74412bb9cda9 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; 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; @@ -63,6 +64,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. @@ -160,6 +163,8 @@ pub enum ThreadSortKey { CreatedAt, /// Sort by the thread last-update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// The direction to use when listing stored threads. @@ -332,13 +337,13 @@ pub struct TurnPage { pub backwards_cursor: Option, } -/// Parameters for listing persisted items within a single turn. +/// Parameters for listing persisted items within a thread. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ListItemsParams { /// Thread id to read. pub thread_id: ThreadId, - /// Turn id to hydrate. - pub turn_id: String, + /// Optional turn id to filter by. When omitted, returns items across the thread. + pub turn_id: Option, /// Whether archived threads are eligible. pub include_archived: bool, /// Opaque cursor returned by a previous list call. @@ -359,7 +364,7 @@ pub struct StoredThreadItem { pub materialized_thread_item_json: Vec, } -/// A page of persisted items within a turn. +/// A page of persisted items within a thread, optionally filtered to a turn. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ItemPage { /// Items returned for this page. @@ -397,6 +402,8 @@ pub struct StoredThread { pub created_at: DateTime, /// Thread last-update timestamp. pub updated_at: DateTime, + /// Thread product-recency timestamp. + pub recency_at: DateTime, /// Thread archive timestamp, if archived. pub archived_at: Option>, /// Working directory captured for the thread. @@ -504,6 +511,8 @@ pub struct ThreadMetadataPatch { pub created_at: Option>, /// Last update timestamp for this metadata observation. pub updated_at: Option>, + /// Advance product recency to at least this timestamp. + pub advance_recency_at: Option>, /// Session source. pub source: Option, /// Optional analytics source classification. @@ -586,6 +595,9 @@ impl ThreadMetadataPatch { if next.updated_at.is_some() { self.updated_at = next.updated_at; } + if next.advance_recency_at.is_some() { + self.advance_recency_at = next.advance_recency_at; + } if next.source.is_some() { self.source = next.source; } @@ -639,6 +651,7 @@ impl ThreadMetadataPatch { && self.reasoning_effort.is_none() && self.created_at.is_none() && self.updated_at.is_none() + && self.advance_recency_at.is_none() && self.source.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() diff --git a/codex-rs/tools/src/request_plugin_install.rs b/codex-rs/tools/src/request_plugin_install.rs index 5d6352b7f8c3..7bc0f6d4c683 100644 --- a/codex-rs/tools/src/request_plugin_install.rs +++ b/codex-rs/tools/src/request_plugin_install.rs @@ -57,7 +57,6 @@ pub fn build_request_plugin_install_elicitation_request( server_name: &str, thread_id: String, turn_id: String, - args: &RequestPluginInstallArgs, suggest_reason: &str, tool: &DiscoverableTool, ) -> McpServerElicitationRequestParams { @@ -69,8 +68,6 @@ pub fn build_request_plugin_install_elicitation_request( server_name: server_name.to_string(), request: McpServerElicitationRequest::Form { meta: Some(json!(build_request_plugin_install_meta( - args.tool_type, - args.action_type, suggest_reason, tool, ))), @@ -105,14 +102,13 @@ pub fn verified_connector_install_completed( } fn build_request_plugin_install_meta<'a>( - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, suggest_reason: &'a str, tool: &'a DiscoverableTool, ) -> RequestPluginInstallMeta<'a> { - let (remote_plugin_id, app_connector_ids) = match tool { - DiscoverableTool::Connector(_) => (None, None), + let (tool_type, remote_plugin_id, app_connector_ids) = match tool { + DiscoverableTool::Connector(_) => (DiscoverableToolType::Connector, None, None), DiscoverableTool::Plugin(plugin) => ( + DiscoverableToolType::Plugin, plugin.remote_plugin_id.as_deref(), Some(plugin.app_connector_ids.as_slice()), ), @@ -121,7 +117,7 @@ fn build_request_plugin_install_meta<'a>( codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE, persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE, tool_type, - suggest_type: action_type, + suggest_type: DiscoverableToolAction::Install, suggest_reason, tool_id: tool.id(), tool_name: tool.name(), diff --git a/codex-rs/tools/src/request_plugin_install_tests.rs b/codex-rs/tools/src/request_plugin_install_tests.rs index ab8af9be6702..400126dc6d87 100644 --- a/codex-rs/tools/src/request_plugin_install_tests.rs +++ b/codex-rs/tools/src/request_plugin_install_tests.rs @@ -5,12 +5,6 @@ use serde_json::json; #[test] fn build_request_plugin_install_elicitation_request_uses_expected_shape() { - let args = RequestPluginInstallArgs { - tool_type: DiscoverableToolType::Connector, - action_type: DiscoverableToolAction::Install, - tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), - suggest_reason: "Plan and reference events from your calendar".to_string(), - }; let connector = DiscoverableTool::Connector(Box::new(AppInfo { id: "connector_2128aebfecb84f64a069897515042a44".to_string(), name: "Google Calendar".to_string(), @@ -34,7 +28,6 @@ fn build_request_plugin_install_elicitation_request_uses_expected_shape() { "codex-apps", "thread-1".to_string(), "turn-1".to_string(), - &args, "Plan and reference events from your calendar", &connector, ); @@ -74,12 +67,6 @@ fn build_request_plugin_install_elicitation_request_uses_expected_shape() { #[test] fn build_request_plugin_install_elicitation_request_injects_plugin_metadata() { - let args = RequestPluginInstallArgs { - tool_type: DiscoverableToolType::Plugin, - action_type: DiscoverableToolAction::Install, - tool_id: "sample@openai-curated-remote".to_string(), - suggest_reason: "Use the sample plugin's skills and MCP server".to_string(), - }; let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { id: "sample@openai-curated-remote".to_string(), remote_plugin_id: Some("plugins~Plugin_sample".to_string()), @@ -94,7 +81,6 @@ fn build_request_plugin_install_elicitation_request_injects_plugin_metadata() { "codex-apps", "thread-1".to_string(), "turn-1".to_string(), - &args, "Use the sample plugin's skills and MCP server", &plugin, ); @@ -149,12 +135,8 @@ fn build_request_plugin_install_meta_uses_expected_shape() { is_enabled: true, plugin_display_names: Vec::new(), })); - let meta = build_request_plugin_install_meta( - DiscoverableToolType::Connector, - DiscoverableToolAction::Install, - "Find and reference emails from your inbox", - &connector, - ); + let meta = + build_request_plugin_install_meta("Find and reference emails from your inbox", &connector); assert_eq!( meta, diff --git a/codex-rs/tools/src/response_history.rs b/codex-rs/tools/src/response_history.rs index 4196d7c3efc9..ab16769afdb2 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/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 98f705363c9f..146cef3b6856 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -38,6 +38,8 @@ pub enum ToolSpec { #[serde(skip_serializing_if = "Option::is_none")] external_web_access: Option, #[serde(skip_serializing_if = "Option::is_none")] + index_gated_web_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] filters: Option, #[serde(skip_serializing_if = "Option::is_none")] user_location: Option, diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index a181cd247bc8..b6f599d5d5f9 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -67,6 +67,7 @@ fn tool_spec_name_covers_all_variants() { assert_eq!( ToolSpec::WebSearch { external_web_access: Some(true), + index_gated_web_access: None, filters: None, user_location: None, search_context_size: None, @@ -199,6 +200,7 @@ fn web_search_tool_spec_serializes_expected_wire_shape() { assert_eq!( serde_json::to_value(ToolSpec::WebSearch { external_web_access: Some(true), + index_gated_web_access: None, filters: Some(ResponsesApiWebSearchFilters { allowed_domains: Some(vec!["example.com".to_string()]), }), diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 201fef5a185e..3318ce62cd02 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -62,6 +62,7 @@ codex-utils-fuzzy-match = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-oss = { workspace = true } codex-utils-path = { workspace = true } +codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } codex-utils-sandbox-summary = { workspace = true } codex-utils-sleep-inhibitor = { workspace = true } @@ -147,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.rs b/codex-rs/tui/src/app.rs index 1c4c11be6419..020add938b47 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -113,7 +113,6 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::SandboxMode as AppServerSandboxMode; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerNotification; @@ -1116,9 +1115,16 @@ See the Codex keymap documentation for supported actions and examples." ); app.refresh_startup_skills(&app_server); // Kick off a non-blocking rate-limit prefetch so the first `/status` - // already has data, without delaying the initial frame render. + // already has data and available reset credits can be surfaced, without + // delaying the initial frame render. if requires_openai_auth && has_chatgpt_account { - app.refresh_rate_limits(&app_server, RateLimitRefreshOrigin::StartupPrefetch); + let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check(); + app.refresh_rate_limits( + &app_server, + RateLimitRefreshOrigin::StartupPrefetch { + reset_hint_request_id, + }, + ); } let mut listen_for_app_server_events = true; diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs index 5b8bc4b59b17..d7583d2b444f 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -12,7 +12,9 @@ fn agent_status_uses_bounded_buffered_activity() { item: ThreadItem::CommandExecution { id: "command-1".to_string(), command: "cargo test -p codex-tui".to_string(), - cwd: AbsolutePathBuf::try_from("/workspace").expect("absolute path"), + cwd: AbsolutePathBuf::try_from("/workspace") + .expect("absolute path") + .into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, 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 fa2bab011155..212c45b4be24 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -24,6 +24,9 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option { ThreadId::from_string(¶ms.thread_id).ok() } + ServerRequest::CurrentTimeRead { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::AttestationGenerate { .. } | ServerRequest::ApplyPatchApproval { .. } @@ -120,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()) } @@ -161,6 +167,7 @@ pub(super) fn server_notification_thread_target( | ServerNotification::AccountRateLimitsUpdated(_) | ServerNotification::AppListUpdated(_) | ServerNotification::RemoteControlStatusChanged(_) + | ServerNotification::ExternalAgentConfigImportProgress(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::DeprecationNotice(_) | ServerNotification::ConfigWarning(_) @@ -226,6 +233,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, } } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index e4ab347776e8..0b728976853b 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -153,6 +153,12 @@ impl PendingAppServerRequests { message: "Attestation generation is not available in TUI.".to_string(), }) } + ServerRequest::CurrentTimeRead { request_id, .. } => { + Some(UnsupportedAppServerRequest { + request_id: request_id.clone(), + message: "External current time is not available in TUI.".to_string(), + }) + } ServerRequest::ApplyPatchApproval { request_id, .. } => { Some(UnsupportedAppServerRequest { request_id: request_id.clone(), @@ -352,6 +358,7 @@ impl PendingAppServerRequests { ServerRequest::DynamicToolCall { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::AttestationGenerate { .. } + | ServerRequest::CurrentTimeRead { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => true, } @@ -452,6 +459,7 @@ mod tests { item_id: "call-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some("ls".to_string()), @@ -791,6 +799,7 @@ mod tests { item_id: "call-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some("ls".to_string()), diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index a486e11e3e8a..4a0652166d72 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -10,6 +10,8 @@ use crate::app_event::ConnectorsSnapshot; use crate::config_update::format_config_error; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams; +use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveParams; @@ -26,6 +28,10 @@ use codex_utils_absolute_path::AbsolutePathBuf; 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( @@ -63,9 +69,9 @@ impl App { /// result as a `RateLimitsLoaded` event. /// /// The `origin` is forwarded to the completion handler so it can distinguish - /// a startup prefetch (which only updates cached snapshots and schedules a - /// frame) from a `/status`-triggered refresh (which must finalize the - /// corresponding status card). + /// a startup prefetch (which updates cached snapshots and may surface a + /// reset-credit notice) from a `/status`-triggered refresh (which must + /// finalize the corresponding status card). pub(super) fn refresh_rate_limits( &mut self, app_server: &AppServerSession, @@ -74,9 +80,20 @@ impl App { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { - let result = fetch_account_rate_limits(request_handle) - .await - .map_err(|err| err.to_string()); + let request = fetch_account_rate_limits(request_handle); + let result = match origin { + RateLimitRefreshOrigin::ResetConsume { .. } => { + tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request) + .await + .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) + .and_then(|result| result.map_err(|err| err.to_string())) + } + RateLimitRefreshOrigin::StartupPrefetch { .. } + | RateLimitRefreshOrigin::StatusCommand { .. } + | RateLimitRefreshOrigin::UsageMenu { .. } => { + request.await.map_err(|err| err.to_string()) + } + }; app_event_tx.send(AppEvent::RateLimitsLoaded { origin, result }); }); } @@ -100,6 +117,72 @@ impl App { }); } + pub(super) fn refresh_rate_limit_reset_credits( + &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( + RATE_LIMIT_RESET_REQUEST_TIMEOUT, + fetch_account_rate_limits(request_handle), + ) + .await + .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) + .and_then(|result| result.map_err(|err| err.to_string())); + app_event_tx.send(AppEvent::RateLimitResetCreditsLoaded { request_id, result }); + }); + } + + pub(super) fn consume_rate_limit_reset_credit( + &mut self, + app_server: &AppServerSession, + request_id: u64, + idempotency_key: String, + ) { + 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( + RATE_LIMIT_RESET_REQUEST_TIMEOUT, + consume_rate_limit_reset_credit_request(request_handle, idempotency_key.clone()), + ) + .await + .map_err(|_| "account/rateLimitResetCredit/consume timed out in TUI".to_string()) + .and_then(|result| result.map_err(|err| err.to_string())); + app_event_tx.send(AppEvent::RateLimitResetCreditConsumed { + request_id, + idempotency_key, + result, + }); + }); + } + + 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, @@ -682,17 +765,15 @@ pub(super) async fn fetch_all_mcp_server_statuses( pub(super) async fn fetch_account_rate_limits( request_handle: AppServerRequestHandle, -) -> Result> { +) -> Result { let request_id = RequestId::String(format!("account-rate-limits-{}", Uuid::new_v4())); - let response: GetAccountRateLimitsResponse = request_handle + request_handle .request_typed(ClientRequest::GetAccountRateLimits { request_id, params: None, }) .await - .wrap_err("account/rateLimits/read failed in TUI")?; - - Ok(app_server_rate_limit_snapshots(response)) + .wrap_err("account/rateLimits/read failed in TUI") } pub(super) async fn fetch_account_token_activity( @@ -708,6 +789,33 @@ pub(super) async fn fetch_account_token_activity( .wrap_err("account/usage/read failed in TUI") } +pub(super) async fn consume_rate_limit_reset_credit_request( + request_handle: AppServerRequestHandle, + idempotency_key: String, +) -> Result { + let request_id = RequestId::String(format!("consume-rate-limit-reset-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::ConsumeAccountRateLimitResetCredit { + request_id, + params: ConsumeAccountRateLimitResetCreditParams { idempotency_key }, + }) + .await + .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 1f7b8e39f858..174c4d14e599 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -246,7 +246,7 @@ impl App { deferred_history_cell, )?; self.chat_widget.note_stream_consolidation_completed(); - self.insert_completed_token_activity_output_after_stream_shutdown(tui); + self.insert_pending_usage_output_after_stream_shutdown(tui); } AppEvent::ConsolidateProposedPlan(source) => { let end = self.transcript_cells.len(); @@ -282,7 +282,7 @@ impl App { self.maybe_finish_stream_reflow(tui)?; } self.chat_widget.note_stream_consolidation_completed(); - self.insert_completed_token_activity_output_after_stream_shutdown(tui); + self.insert_pending_usage_output_after_stream_shutdown(tui); } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { @@ -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; } @@ -750,28 +756,141 @@ impl App { .finish_add_credits_nudge_email_request(result); } AppEvent::RateLimitsLoaded { origin, result } => match result { - Ok(snapshots) => { - for snapshot in snapshots { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); - } + Ok(response) => { + let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); + let snapshots = app_server_rate_limit_snapshots(response); match origin { - RateLimitRefreshOrigin::StartupPrefetch => { + 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() + }), + ) { + self.insert_pending_usage_output_if_ready(tui); + } + tui.frame_requester().schedule_frame(); + } + 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() + }), + ); tui.frame_requester().schedule_frame(); } 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() + }), + ); } } } Err(err) => { tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}"); - if let RateLimitRefreshOrigin::StatusCommand { request_id } = origin { - self.chat_widget - .finish_status_rate_limit_refresh(request_id); + match origin { + RateLimitRefreshOrigin::StartupPrefetch { + reset_hint_request_id, + } => { + 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, + Vec::new(), + Err(err), + ); + } + RateLimitRefreshOrigin::StatusCommand { request_id } => { + self.chat_widget + .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), + ); + } } } }, + AppEvent::OpenTokenActivity => { + self.chat_widget + .add_token_activity_output(crate::chatwidget::TokenActivityView::Daily); + } + AppEvent::OpenRateLimitResetCredits => { + 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 } => 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), + ); + } + }, + 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); + } + AppEvent::RateLimitResetCreditConsumed { + request_id, + idempotency_key, + result, + } => { + if let Err(err) = &result { + tracing::warn!( + "account/rateLimitResetCredit/consume failed during TUI request: {err}" + ); + } + if self.chat_widget.finish_rate_limit_reset_consume( + request_id, + idempotency_key, + result, + ) { + self.refresh_rate_limits( + app_server, + RateLimitRefreshOrigin::ResetConsume { request_id }, + ); + } + } AppEvent::TokenActivityLoaded { request_id, result } => { if let Err(err) = &result { tracing::warn!("account/usage/read failed during TUI refresh: {err}"); @@ -785,11 +904,14 @@ impl App { // active work, and flushing an in-progress tool cell would corrupt its lifecycle. // If an answer stream is active, keep the settled card transient until its // provisional transcript cells have been consolidated. - self.insert_completed_token_activity_output_if_ready(tui); + self.insert_pending_usage_output_if_ready(tui); } } - AppEvent::CommitCompletedTokenActivityOutput => { - self.insert_completed_token_activity_output_after_stream_shutdown(tui); + AppEvent::CommitPendingUsageOutput => { + self.insert_pending_usage_output_if_ready(tui); + } + AppEvent::CommitPendingUsageOutputAfterStreamShutdown => { + self.insert_pending_usage_output_after_stream_shutdown(tui); } AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); @@ -811,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); } @@ -1929,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/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 59ec0d3dcb03..d527df7a9014 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -32,36 +32,38 @@ impl App { } // A committed cell can unblock a settled /usage card that was waiting // behind a transient active cell or a provisional stream tail. - self.chat_widget - .request_completed_token_activity_output_insertion(); + self.chat_widget.request_pending_usage_output_insertion(); } - pub(super) fn insert_completed_token_activity_output_if_ready(&mut self, tui: &mut tui::Tui) { - if self.chat_widget.token_activity_history_insertion_blocked() - || self.transcript_cells.last().is_some_and(|cell| { - cell.as_any().is::() - || cell.as_any().is::() - }) - { - return; - } - self.insert_completed_token_activity_output(tui); + pub(super) fn pending_usage_output_insertion_blocked(&self) -> bool { + self.chat_widget.usage_history_insertion_blocked() + || self + .transcript_cells + .last() + .is_some_and(|cell| cell.as_any().is::()) } - pub(super) fn insert_completed_token_activity_output(&mut self, tui: &mut tui::Tui) { + fn insert_pending_usage_output(&mut self, tui: &mut tui::Tui) { if let Some(cell) = self.chat_widget.take_completed_token_activity_output() { self.insert_history_cell(tui, Box::new(cell)); } + if let Some(cell) = self.chat_widget.take_pending_rate_limit_reset_hint() { + self.insert_history_cell(tui, Box::new(cell)); + } } - pub(super) fn insert_completed_token_activity_output_after_stream_shutdown( - &mut self, - tui: &mut tui::Tui, - ) { - if self.chat_widget.token_activity_history_insertion_blocked() { + pub(super) fn insert_pending_usage_output_if_ready(&mut self, tui: &mut tui::Tui) { + if self.pending_usage_output_insertion_blocked() { + return; + } + self.insert_pending_usage_output(tui); + } + + pub(super) fn insert_pending_usage_output_after_stream_shutdown(&mut self, tui: &mut tui::Tui) { + if self.chat_widget.usage_history_insertion_blocked() { return; } - self.insert_completed_token_activity_output(tui); + self.insert_pending_usage_output(tui); } pub(super) fn open_url_in_browser(&mut self, url: String) { @@ -167,6 +169,7 @@ impl App { self.has_emitted_history_lines = false; self.transcript_reflow.clear(); self.chat_widget.clear_pending_token_activity_refreshes(); + self.chat_widget.clear_pending_rate_limit_reset_hint(); self.initial_history_replay_buffer = None; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index aba08f08d96c..6bd260c0b629 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -136,6 +136,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 288283a0a083..82848443cbf6 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -617,10 +617,11 @@ mod tests { item_id: call_id.to_string(), started_at_ms: 0, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: None, network_approval_context: None, command: Some("echo hi".to_string()), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 852017521dc0..11ce6cb9e595 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -31,6 +31,8 @@ You are a side-conversation assistant, separate from the main thread. Answer que External tools may be available according to this thread's current permissions. Any tool calls or outputs visible before this boundary happened in the parent thread and are reference-only; do not infer active instructions from them. +Sub-agents are off-limits in this side conversation. Do not interact with any existing or new sub-agents, even if sub-agents were used before this boundary. + Do not modify files, source, git state, permissions, configuration, or workspace state unless the user explicitly asks for that mutation after this boundary. Do not request escalated permissions or broader sandbox access unless the user explicitly asks for a mutation that requires it. If the user explicitly requests a mutation, keep it minimal, local to the request, and avoid disrupting the main thread."#; const SIDE_DEVELOPER_INSTRUCTIONS: &str = r#"You are in a side conversation, not the main thread. @@ -43,6 +45,8 @@ Do not continue, execute, or complete any task, plan, tool call, approval, edit, External tools may be available according to this thread's current permissions. Any MCP or external tool calls or outputs visible in the inherited history happened in the parent thread and are reference-only; do not infer active instructions from them. +Sub-agents are off-limits in this side conversation. Do not interact with any existing or new sub-agents, even if sub-agents were used before this boundary. + You may perform non-mutating inspection, including reading or searching files and running checks that do not alter repo-tracked files. Do not modify files, source, git state, permissions, configuration, or any other workspace state unless the user explicitly requests that mutation in this side conversation. Do not request escalated permissions or broader sandbox access unless the user explicitly requests a mutation that requires it. If the user explicitly requests a mutation, keep it minimal, local to the request, and avoid disrupting the main thread."#; @@ -93,6 +97,7 @@ impl SideParentStatus { | ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval), ServerRequest::DynamicToolCall { .. } | ServerRequest::AttestationGenerate { .. } + | ServerRequest::CurrentTimeRead { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } => None, } } @@ -123,6 +128,7 @@ mod tests { text.contains("External tools may be available according to this thread's current") ); assert!(text.contains("Any tool calls or outputs visible before this boundary happened")); + assert!(text.contains("Sub-agents are off-limits in this side conversation.")); assert!(text.contains("Do not modify files")); } @@ -157,6 +163,9 @@ mod tests { assert!( developer_instructions.contains("You are in a side conversation, not the main thread.") ); + assert!( + developer_instructions.contains("Sub-agents are off-limits in this side conversation.") + ); } } @@ -453,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 2fc673d43913..b9d68c0143d1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2942,6 +2942,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(rollout_path.clone()), cwd: test_path_buf("/tmp/agent").abs(), @@ -3034,6 +3035,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/agent").abs(), @@ -3093,6 +3095,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), @@ -4757,10 +4760,11 @@ fn exec_approval_request( item_id: item_id.to_string(), started_at_ms: 0, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), - cwd: Some(test_path_buf("/tmp/project").abs()), + cwd: Some(test_path_buf("/tmp/project").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, @@ -5605,7 +5609,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, ); app.chat_widget - .set_composer_text("/usage".to_string(), Vec::new(), Vec::new()); + .set_composer_text("/usage daily".to_string(), Vec::new(), Vec::new()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.chat_widget @@ -5646,6 +5650,38 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { assert_eq!(overlay_cell_count, app.transcript_cells.len()); } +#[tokio::test] +async fn late_usage_result_can_follow_finalized_plan() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget + .add_token_activity_output(crate::chatwidget::TokenActivityView::Daily); + let request_id = match app_event_rx.try_recv() { + Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id, + other => panic!("expected token activity refresh request, got {other:?}"), + }; + + app.chat_widget.note_stream_consolidation_queued(); + app.transcript_cells + .push(Arc::new(history_cell::new_proposed_plan_stream( + vec![Line::from("finalized plan")], + /*is_stream_continuation*/ false, + ))); + app.chat_widget.note_stream_consolidation_completed(); + + assert!( + app.chat_widget.finish_token_activity_refresh( + request_id, + Err("token activity unavailable".to_string()), + ) + ); + assert!(!app.pending_usage_output_insertion_blocked()); + assert!( + app.chat_widget + .take_completed_token_activity_output() + .is_some() + ); +} + #[tokio::test] async fn thread_rollback_response_discards_queued_active_thread_events() { let mut app = make_test_app().await; @@ -5678,6 +5714,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), @@ -6093,6 +6130,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode effort: collaboration_mode.settings.reasoning_effort.clone(), summary: None, collaboration_mode: collaboration_mode.clone(), + multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, }; diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 48dc1d08487a..00b7fcdedb18 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -488,10 +488,11 @@ mod tests { item_id: item_id.to_string(), started_at_ms: 0, approval_id: approval_id.map(str::to_string), + environment_id: None, reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), - cwd: Some(test_path_buf("/tmp/project").abs()), + cwd: Some(test_path_buf("/tmp/project").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index ee48028ddb79..240c756254e4 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -226,6 +226,7 @@ impl App { .approval_id .clone() .unwrap_or_else(|| params.item_id.clone()), + environment_id: params.environment_id.clone(), command: params .command .as_deref() @@ -291,7 +292,10 @@ impl App { message: message.clone(), }, )), - codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { + .. + } + | codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name.clone(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index bcc172ef93dc..ceafb71db923 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -417,6 +417,7 @@ mod tests { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 04020787ad41..0870093883d6 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -541,6 +541,7 @@ impl App { return false; } self.chat_widget.clear_pending_token_activity_refreshes(); + self.chat_widget.clear_pending_rate_limit_reset_hint(); self.chat_widget .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); self.sync_overlay_after_transcript_trim(); @@ -565,6 +566,7 @@ impl App { pending.selection.nth_user_message, ) { self.chat_widget.clear_pending_token_activity_refreshes(); + self.chat_widget.clear_pending_rate_limit_reset_hint(); self.chat_widget .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); self.sync_overlay_after_transcript_trim(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3f6189f27e2b..55d67f29268a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -13,6 +13,8 @@ use std::path::PathBuf; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; +use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountTokenUsageResponse; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; @@ -25,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::RateLimitSnapshot; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; @@ -114,17 +115,23 @@ pub(crate) struct PluginRemoteSectionError { /// handler can route the result correctly. /// /// A `StartupPrefetch` fires once, concurrently with the rest of TUI init, and -/// only updates the cached snapshots (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. +/// 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. 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 so the first `/status` already has data. - StartupPrefetch, + /// Eagerly fetched after bootstrap for `/status` data and reset availability. + StartupPrefetch { reset_hint_request_id: u64 }, /// 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 }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -299,7 +306,31 @@ pub(crate) enum AppEvent { /// Result of refreshing rate limits. RateLimitsLoaded { origin: RateLimitRefreshOrigin, - result: Result, String>, + result: Result, + }, + + /// Open the default token-activity view selected from the `/usage` menu. + OpenTokenActivity, + + /// Open the reset-credit flow selected from the `/usage` menu. + OpenRateLimitResetCredits, + + /// Result of reading the current reset-credit balance. + RateLimitResetCreditsLoaded { + request_id: u64, + result: Result, + }, + + /// Consume one reset credit using a stable idempotency key. + ConsumeRateLimitResetCredit { + idempotency_key: String, + }, + + /// Result of consuming one reset credit. + RateLimitResetCreditConsumed { + request_id: u64, + idempotency_key: String, + result: Result, }, /// Fetch account-wide token activity for a `/usage` history card. @@ -313,8 +344,16 @@ pub(crate) enum AppEvent { result: Result, }, - /// Commit a settled token activity card after a stream shutdown barrier. - CommitCompletedTokenActivityOutput, + /// Fetch workspace messages for the status-line headline item. + RefreshStatusLineWorkspaceHeadline { + request_id: u64, + }, + + /// Commit settled asynchronous usage output after active-output barriers clear. + CommitPendingUsageOutput, + + /// Commit settled asynchronous usage output after stream shutdown. + CommitPendingUsageOutputAfterStreamShutdown, /// Send a user-confirmed request to notify the workspace owner. SendAddCreditsNudgeEmail { @@ -414,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, @@ -663,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, @@ -946,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 bfbffc47bcef..6c85ca522af5 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -123,6 +123,7 @@ use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -366,7 +367,10 @@ impl AppServerSession { .client .request_typed(ClientRequest::ExternalAgentConfigImport { request_id, - params: ExternalAgentConfigImportParams { migration_items }, + params: ExternalAgentConfigImportParams { + migration_items, + source: None, + }, }) .await .wrap_err("externalAgentConfig/import failed during Claude Code import"); @@ -757,6 +761,7 @@ impl AppServerSession { personality, output_schema, collaboration_mode, + multi_agent_mode: None, }, }) .await @@ -1189,12 +1194,12 @@ pub(crate) fn account_ui_state_from_response(account: &GetAccountResponse) -> Ac has_chatgpt_account: false, }, Some(Account::Chatgpt { email, plan_type }) => { - let feedback_audience = feedback_audience_for_email(Some(email.as_str())); + let feedback_audience = feedback_audience_for_email(email.as_deref()); AccountUiState { - account_email: Some(email.clone()), + account_email: email.clone(), auth_mode: Some(TelemetryAuthMode::Chatgpt), status_account_display: Some(StatusAccountDisplay::ChatGpt { - email: Some(email.clone()), + email: email.clone(), plan: Some(plan_type_display_name(*plan_type)), }), plan_type: Some(*plan_type), @@ -1243,7 +1248,7 @@ pub(crate) fn account_ui_state_from_response(account: &GetAccountResponse) -> Ac has_chatgpt_account: true, } } - Some(Account::AmazonBedrock {}) | None => AccountUiState { + Some(Account::AmazonBedrock { .. }) | None => AccountUiState { account_email: None, auth_mode: None, status_account_display: None, @@ -1639,7 +1644,7 @@ async fn thread_session_state_from_thread_start_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1680,7 +1685,7 @@ async fn thread_session_state_from_thread_resume_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1712,7 +1717,7 @@ async fn thread_session_state_from_thread_fork_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1751,7 +1756,7 @@ async fn thread_session_state_from_thread_response( active_permission_profile: Option, cwd: AbsolutePathBuf, runtime_workspace_roots: Vec, - instruction_source_paths: Vec, + instruction_source_paths: Vec, reasoning_effort: Option, config: &Config, ) -> Result { @@ -1841,6 +1846,7 @@ mod tests { use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -2404,6 +2410,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), @@ -2448,7 +2455,9 @@ mod tests { test_path_buf("/tmp/project").abs(), test_path_buf("/tmp/project/extra").abs(), ], - instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], + instruction_sources: vec![LegacyAppPathString::from_abs_path( + &test_path_buf("/tmp/project/AGENTS.md").abs(), + )], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, sandbox: read_only_profile @@ -2457,6 +2466,7 @@ mod tests { .into(), active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }; @@ -2474,7 +2484,7 @@ mod tests { ); assert_eq!( started.session.instruction_source_paths, - response.instruction_sources + response.instruction_source_path_uris() ); assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); diff --git a/codex-rs/tui/src/approval_events.rs b/codex-rs/tui/src/approval_events.rs index 57e0959d5633..ba183225b872 100644 --- a/codex-rs/tui/src/approval_events.rs +++ b/codex-rs/tui/src/approval_events.rs @@ -26,6 +26,8 @@ pub(crate) struct ExecApprovalRequestEvent { pub(crate) approval_id: Option, #[serde(default)] pub(crate) turn_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) environment_id: Option, pub(crate) command: Vec, pub(crate) cwd: AbsolutePathBuf, pub(crate) reason: Option, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index b321aeb159f4..3cc135da74a0 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -74,6 +74,7 @@ pub(crate) enum ApprovalRequest { thread_id: ThreadId, thread_label: Option, id: String, + environment_id: Option, command: Vec, reason: Option, available_decisions: Vec, @@ -675,6 +676,7 @@ fn build_header(request: &ApprovalRequest) -> Box { match request { ApprovalRequest::Exec { thread_label, + environment_id, reason, command, network_approval_context, @@ -689,6 +691,13 @@ fn build_header(request: &ApprovalRequest) -> Box { ])); header.push(Line::from("")); } + if let Some(environment_id) = environment_id { + header.push(Line::from(vec![ + "Environment: ".into(), + environment_id.clone().bold(), + ])); + header.push(Line::from("")); + } if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); @@ -1220,6 +1229,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), available_decisions: vec![ @@ -1360,6 +1370,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1403,6 +1414,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["curl".to_string(), "https://example.com".to_string()], reason: None, available_decisions: vec![ @@ -1477,6 +1489,7 @@ mod tests { thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1511,6 +1524,7 @@ mod tests { thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1549,6 +1563,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string(), "hi".to_string()], reason: None, available_decisions: vec![ @@ -1577,6 +1592,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["echo".to_string()], reason: None, available_decisions: vec![ @@ -1629,6 +1645,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), + environment_id: None, command: vec!["curl".to_string(), "https://example.com".to_string()], reason: None, available_decisions: vec![ @@ -1668,6 +1685,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command, reason: None, available_decisions: vec![ @@ -1966,6 +1984,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec!["cat".into(), "/tmp/readme.txt".into()], reason: None, available_decisions: vec![ @@ -2022,6 +2041,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec!["cat".into(), "/tmp/readme.txt".into()], reason: Some("need filesystem access".into()), available_decisions: vec![ @@ -2102,6 +2122,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec!["curl".into(), "https://example.com".into()], reason: Some("network request blocked".into()), available_decisions: vec![ @@ -2238,6 +2259,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, id: "test".into(), + environment_id: None, command: vec![ "network-access".to_string(), "https://example.com:8443".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index a246c05e5706..551bacd25acb 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -67,6 +67,11 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Return true when this key event will interrupt the active agent turn. + fn will_interrupt_turn_on_key_event(&self, _key_event: KeyEvent) -> bool { + false + } + /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 317d26965cb6..7af981d7ded5 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -200,6 +200,9 @@ pub(crate) struct SelectionViewParams { /// Receives the *actual* item index, not the filtered/visible index. pub on_selection_changed: OnSelectionChangedCallback, + /// Whether cancellation keys can dismiss the picker. + pub allow_cancel: bool, + /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. pub on_cancel: OnCancelCallback, } @@ -229,6 +232,7 @@ impl Default for SelectionViewParams { stacked_side_content: None, preserve_side_content_bg: false, on_selection_changed: None, + allow_cancel: true, on_cancel: None, } } @@ -270,6 +274,8 @@ pub(crate) struct ListSelectionView { /// Called when the highlighted item changes (navigation, filter, number-key). on_selection_changed: OnSelectionChangedCallback, + allow_cancel: bool, + /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. on_cancel: OnCancelCallback, keymap: ListKeymap, @@ -342,6 +348,7 @@ impl ListSelectionView { stacked_side_content: params.stacked_side_content, preserve_side_content_bg: params.preserve_side_content_bg, on_selection_changed: params.on_selection_changed, + allow_cancel: params.allow_cancel, on_cancel: params.on_cancel, keymap, }; @@ -968,7 +975,7 @@ impl BottomPaneView for ListSelectionView { } if self.is_searchable && self.search_query.is_empty() && self.selected_item_has_toggle_placeholder() => {} - _ if self.keymap.cancel.is_pressed(key_event) => { + _ if self.allow_cancel && self.keymap.cancel.is_pressed(key_event) => { self.on_ctrl_c(); } _ if self.keymap.accept.is_pressed(key_event) => self.accept(), @@ -1062,6 +1069,9 @@ impl BottomPaneView for ListSelectionView { } fn on_ctrl_c(&mut self) -> CancellationEvent { + if !self.allow_cancel { + return CancellationEvent::NotHandled; + } if let Some(cb) = &self.on_cancel { cb(&self.app_event_tx); } diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs index c8031a01370d..c2fa1f6a1418 100644 --- a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs @@ -9,8 +9,7 @@ use ratatui::text::Span; use ratatui::widgets::Widget; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; -use crate::render::Insets; -use crate::render::RectExt; +use crate::style::accent_style; use super::candidate::MentionType; use super::candidate::SearchResult; @@ -46,15 +45,7 @@ pub(super) fn render_popup( (area, None) }; - render_rows( - list_area.inset(Insets::tlbr( - /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, - )), - buf, - rows, - state, - empty_message, - ); + render_rows(list_area, buf, rows, state, empty_message); if let Some(hint_area) = hint_area { let hint_area = Rect { @@ -78,7 +69,7 @@ fn render_rows( return; } if rows.is_empty() { - Line::from(empty_message.italic()).render(area, buf); + Line::from(vec![" ".into(), empty_message.italic()]).render(area, buf); return; } @@ -131,31 +122,34 @@ fn build_line( width: usize, primary_column_width: usize, ) -> Line<'static> { - let base_style = if selected { - Style::default().bold() - } else { - Style::default() - }; - let dim_style = if selected { - Style::default().bold() - } else { - Style::default().dim() - }; + let base_style = Style::default(); + let dim_style = Style::default().dim(); let tag = row.mention_type.span(base_style); let tag_width = tag.width(); - let content_width = width.saturating_sub(tag_width.saturating_add(2)); + let gutter = if selected { "> " } else { " " }; + let gutter_width = gutter.len(); + let content_width = + width.saturating_sub(gutter_width.saturating_add(tag_width).saturating_add(2)); let content = truncate_line_with_ellipsis_if_overflow( content_line(row, base_style, dim_style, primary_column_width), content_width, ); let rendered_content_width = content.width(); - let mut spans = Vec::new(); + let mut spans = vec![gutter.into()]; spans.extend(content.spans); - let padding = width.saturating_sub(rendered_content_width.saturating_add(tag_width)); + let padding = width.saturating_sub( + gutter_width + .saturating_add(rendered_content_width) + .saturating_add(tag_width), + ); if padding > 0 { spans.push(" ".repeat(padding).set_style(dim_style)); } spans.push(tag); + if selected { + let style = accent_style(); + spans.iter_mut().for_each(|span| span.style = style); + } Line::from(spans) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index dd032124efc8..dece656522f3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -627,21 +627,10 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - let is_agent_command = self - .composer_text() - .lines() - .next() - .and_then(parse_slash_name) - .is_some_and(|(name, _, _)| name == "agent"); - // If a task is running and a status line is visible, allow the // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. - if self.keymap.chat.interrupt_turn.is_pressed(key_event) - && self.is_task_running - && !(is_agent_command && key_event.code == KeyCode::Esc) - && !self.composer.popup_active() - && !self.composer_should_handle_vim_insert_escape(key_event) + if self.should_interrupt_running_task(key_event) && let Some(status) = &self.status { // Send Op::Interrupt @@ -1097,6 +1086,14 @@ impl BottomPane { } fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) { + if !params.allow_cancel { + if params.footer_hint.is_none() + || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) + { + params.footer_hint = None; + } + return; + } if params.footer_hint.is_none() || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) { @@ -1129,6 +1126,34 @@ impl BottomPane { true } + /// Replace the newest matching selection view without disturbing views stacked above it. + pub(crate) fn replace_selection_view_if_present( + &mut self, + view_id: &'static str, + mut params: list_selection_view::SelectionViewParams, + ) -> bool { + let Some(index) = self + .view_stack + .iter() + .rposition(|view| view.view_id() == Some(view_id)) + else { + return false; + }; + + let replaces_active_view = index + 1 == self.view_stack.len(); + self.apply_standard_popup_hint(&mut params); + self.view_stack[index] = Box::new(list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + )); + if replaces_active_view { + self.schedule_active_view_frame(); + } + self.request_redraw(); + true + } + pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> { popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list) } @@ -1199,6 +1224,25 @@ impl BottomPane { true } + /// Dismiss the newest matching view without disturbing views stacked above it. + pub(crate) fn dismiss_view_by_id(&mut self, view_id: &'static str) -> bool { + let Some(index) = self + .view_stack + .iter() + .rposition(|view| view.view_id() == Some(view_id)) + else { + return false; + }; + + let removed_active_view = index + 1 == self.view_stack.len(); + self.view_stack.remove(index); + if removed_active_view { + self.schedule_active_view_frame(); + } + self.request_redraw(); + true + } + /// Update the pending-input preview shown above the composer. pub(crate) fn set_pending_input_preview( &mut self, @@ -1262,6 +1306,22 @@ impl BottomPane { self.is_task_running } + pub(crate) fn should_interrupt_running_task(&self, key_event: KeyEvent) -> bool { + let is_agent_command = self + .composer_text() + .lines() + .next() + .and_then(parse_slash_name) + .is_some_and(|(name, _, _)| name == "agent"); + + self.keymap.chat.interrupt_turn.is_pressed(key_event) + && self.is_task_running + && !(is_agent_command && key_event.code == KeyCode::Esc) + && self.no_modal_or_popup_active() + && !self.composer_should_handle_vim_insert_escape(key_event) + && self.status.is_some() + } + pub(crate) fn terminal_title_requires_action(&self) -> bool { self.active_view() .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) @@ -1271,6 +1331,13 @@ impl BottomPane { !self.view_stack.is_empty() } + pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) + } + #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) @@ -1852,6 +1919,7 @@ mod tests { thread_id: codex_protocol::ThreadId::new(), thread_label: None, id: "1".to_string(), + environment_id: None, command: vec!["echo".into(), "ok".into()], reason: None, available_decisions: vec![ diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index acbf61568491..bf1fa5fa8ae0 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1183,6 +1183,21 @@ impl BottomPaneView for RequestUserInputOverlay { true } + fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + if KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL).is_press(key_event) { + return self.confirm_unanswered_active() + || !self.focus_is_notes() + || self.composer.current_text_with_pending().is_empty(); + } + + key_event.kind != KeyEventKind::Release + && !self.confirm_unanswered_active() + && !(matches!(key_event.code, KeyCode::Esc) + && self.has_options() + && self.notes_ui_visible()) + && self.interrupt_turn_keys.is_pressed(key_event) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index a605282d71e1..513808c0df2c 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -53,7 +53,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, Self::Workflow(_) => false, } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap index 797e4113f0b3..bf21e4ea6166 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap @@ -5,7 +5,7 @@ expression: terminal.backend() " " "› @sa " " " -" Sample Plugin Plugin with skills and an MCP server Plugin" +"> Sample Plugin Plugin with skills and an MCP server Plugin" " " " " " " 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 4dd62a7d20f5..63ce4237dbe4 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 170c4641d2b2..4198943a4f79 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 bd0a94a4d40c..b8b7a6fbd4a9 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 fa474b8c2225..e1991d24d32e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -164,6 +164,7 @@ use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::resume_hint; +use codex_utils_path_uri::PathUri; use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; use crossterm::event::KeyCode; @@ -366,6 +367,7 @@ use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; use self::skills::is_app_mentionable; +mod plugin_catalog; mod plugins; use self::plugins::PluginInstallAuthFlowState; use self::plugins::PluginListFetchState; @@ -410,6 +412,7 @@ mod status_surfaces; mod streaming; use self::status_surfaces::CachedProjectRootName; mod tokens; +pub(crate) use self::tokens::TokenActivityView; mod tool_lifecycle; mod tool_requests; mod transcript; @@ -417,6 +420,7 @@ use self::transcript::TranscriptState; mod turn_lifecycle; mod turn_runtime; use self::turn_lifecycle::TurnLifecycleState; +mod usage; mod user_messages; use self::user_messages::PendingSteer; use self::user_messages::PendingSteerCompareKey; @@ -553,6 +557,12 @@ pub(crate) struct ChatWidget { refreshing_token_activity_output: Option, completed_token_activity_output: Option, 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, plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, @@ -682,7 +692,7 @@ pub(crate) struct ChatWidget { // App-server-backed command runner for status-line workspace metadata lookups. workspace_command_runner: Option, // Instruction source files loaded for the current session, supplied by app-server. - instruction_source_paths: Vec, + instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. @@ -721,6 +731,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, @@ -835,6 +855,12 @@ fn exec_approval_request_from_params( params: CommandExecutionRequestApprovalParams, fallback_cwd: &AbsolutePathBuf, ) -> ExecApprovalRequestEvent { + // TODO(anp): Keep this as PathUri once `tui::approval_events::ExecApprovalRequestEvent` and + // approval rendering support foreign paths. + let cwd = params + .cwd + .and_then(|cwd| cwd.to_inferred_abs_path()) + .unwrap_or_else(|| fallback_cwd.clone()); ExecApprovalRequestEvent { call_id: params.item_id, command: params @@ -842,12 +868,13 @@ fn exec_approval_request_from_params( .as_deref() .map(split_command_string) .unwrap_or_default(), - cwd: params.cwd.unwrap_or_else(|| fallback_cwd.clone()), + cwd, reason: params.reason, network_approval_context: params.network_approval_context, additional_permissions: params.additional_permissions, turn_id: params.turn_id, approval_id: params.approval_id, + environment_id: params.environment_id, proposed_execpolicy_amendment: params.proposed_execpolicy_amendment, proposed_network_policy_amendments: params.proposed_network_policy_amendments, available_decisions: params.available_decisions, @@ -1175,13 +1202,14 @@ impl ChatWidget { { self.refresh_terminal_title(); } + self.refresh_status_line_if_workspace_headline_due(); } fn flush_active_cell(&mut self) { if let Some(active) = self.transcript.active_cell.take() { self.transcript.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion(); } } @@ -1396,7 +1424,7 @@ impl ChatWidget { tool.mark_failed(); } self.add_boxed_history(cell); - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion(); } } @@ -1887,9 +1915,9 @@ impl ChatWidget { /// Returns a cache key describing the current in-flight cells for the transcript overlay. /// /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the - /// current active, hook, and token activity cells, and the overlay caches that tail; this key is - /// what it uses to decide whether it must recompute. When there are no live cells, this returns - /// `None` so the overlay can drop the tail entirely. + /// current active, hook, and asynchronous usage cells, and the overlay caches that tail; this + /// key is what it uses to decide whether it must recompute. When there are no live cells, this + /// returns `None` so the overlay can drop the tail entirely. /// /// If callers mutate the active cell's transcript output without bumping the revision (or /// providing an appropriate animation tick), the overlay will keep showing a stale tail while @@ -1898,7 +1926,12 @@ impl ChatWidget { let cell = self.transcript.active_cell.as_ref(); let hook_cell = self.active_hook_cell.as_ref(); let token_activity_cell = self.pending_token_activity_output(); - if cell.is_none() && hook_cell.is_none() && token_activity_cell.is_none() { + let rate_limit_reset_hint = self.pending_rate_limit_reset_hint(); + if cell.is_none() + && hook_cell.is_none() + && token_activity_cell.is_none() + && rate_limit_reset_hint.is_none() + { return None; } Some(ActiveCellTranscriptKey { @@ -1943,6 +1976,13 @@ impl ChatWidget { } lines.extend(token_activity_lines); } + if let Some(rate_limit_reset_hint) = self.pending_rate_limit_reset_hint() { + let hint_lines = rate_limit_reset_hint.transcript_hyperlink_lines(width); + if !hint_lines.is_empty() && !lines.is_empty() { + lines.push(HyperlinkLine::from("")); + } + lines.extend(hint_lines); + } (!lines.is_empty()).then_some(lines) } diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 753c21dc90d5..d8a1df4b7224 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -130,6 +130,12 @@ impl ChatWidget { refreshing_token_activity_output: None, completed_token_activity_output: None, 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, plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), @@ -225,6 +231,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/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs index 129e6381fb8c..54a3e7eee3ca 100644 --- a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -10,7 +10,7 @@ impl ChatWidget { pub(super) fn clear_active_hook_cell(&mut self) { if self.active_hook_cell.take().is_some() { self.bump_active_cell_revision(); - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion(); } } @@ -85,7 +85,7 @@ impl ChatWidget { self.transcript.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion(); } pub(super) fn finish_active_hook_cell_if_idle(&mut self) { @@ -95,7 +95,7 @@ impl ChatWidget { if cell.is_empty() { self.active_hook_cell = None; self.bump_active_cell_revision(); - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion(); return; } if cell.should_flush() @@ -105,7 +105,7 @@ impl ChatWidget { self.transcript.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(cell))); - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion(); } } diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 308b6e1860c7..6408989fa8d8 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('!') @@ -75,6 +76,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()); } @@ -90,7 +105,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 25e93088da70..ba0aeb0a9156 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -17,9 +17,15 @@ impl ChatWidget { && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) { + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(key_event); self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + 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; } @@ -133,7 +139,9 @@ impl ChatWidget { && !self.should_handle_vim_insert_escape(key_event) { self.input_queue.submit_pending_steers_after_interrupt = true; - if !self.submit_op(AppCommand::interrupt()) { + if self.submit_op(AppCommand::interrupt()) { + self.pause_active_goal_for_interrupt(); + } else { self.input_queue.submit_pending_steers_after_interrupt = false; } return; @@ -165,7 +173,12 @@ impl ChatWidget { } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = + self.bottom_pane.should_interrupt_running_task(key_event); let input_result = self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } self.handle_composer_input_result(input_result, had_modal_or_popup); } } @@ -360,6 +373,12 @@ impl ChatWidget { fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { @@ -370,6 +389,12 @@ impl ChatWidget { self.arm_quit_shortcut(key); } } + 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; } @@ -378,8 +403,9 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); - self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); + if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { + self.pause_active_goal_for_interrupt(); + } } else { self.request_quit_without_confirmation(); } @@ -395,9 +421,10 @@ impl ChatWidget { self.arm_quit_shortcut(key); - if self.is_cancellable_work_active() { + if self.is_cancellable_work_active() + && self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) + { self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); } } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 301f91841882..7b4c23e273e6 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -161,6 +161,7 @@ mod tests { call_id: call_id.to_string(), approval_id: approval_id.map(str::to_string), turn_id: "turn".to_string(), + environment_id: None, command: vec!["true".to_string()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, @@ -176,7 +177,7 @@ mod tests { ThreadItem::CommandExecution { id: call_id.to_string(), command: "true".to_string(), - cwd: AbsolutePathBuf::current_dir().expect("current dir"), + cwd: AbsolutePathBuf::current_dir().expect("current dir").into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index c9cb3b108fb3..c97721819ebb 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 new file mode 100644 index 000000000000..a19ede3ab5ab --- /dev/null +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -0,0 +1,1996 @@ +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; +use std::time::Instant; + +use super::ChatWidget; +use super::plugins::ADD_MARKETPLACE_TAB_ID; +use super::plugins::ALL_PLUGINS_TAB_ID; +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; +use crate::bottom_pane::SelectionRowDisplay; +use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionToggle; +use crate::bottom_pane::SelectionViewParams; +use crate::key_hint; +use crate::legacy_core::config::Config; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; +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; +use ratatui::prelude::Widget; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthStr; + +const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; +const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; +const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; +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, + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, +} + +impl DelayedLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + loading_text, + note, + } + } +} + +impl Renderable for DelayedLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(3); + lines.push(Line::from("Plugins".bold())); + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.started_at); + if elapsed < LOADING_ANIMATION_DELAY { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); + lines.push(Line::from(self.loading_text.as_str().dim())); + } else if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + lines.push(Line::from(shimmer_text( + self.loading_text.as_str(), + MotionMode::Animated, + ))); + } else { + lines.push(Line::from(self.loading_text.as_str().dim())); + } + + if let Some(note) = &self.note { + lines.push(Line::from(note.as_str().dim())); + } + + Paragraph::new(lines).render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 2 + u16::from(self.note.is_some()) + } +} + +struct PluginDisclosureLine { + line: Line<'static>, +} + +impl Renderable for PluginDisclosureLine { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .render(area, buf); + mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(u16::MAX) + } +} + +impl ChatWidget { + pub(super) fn plugins_loading_popup_params(&self) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Loading available plugins...".to_string(), + Some("This updates when the marketplace list is ready.".to_string()), + )), + items: vec![SelectionItem { + name: "Loading plugins...".to_string(), + description: Some("This updates when the marketplace list is ready.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Adding marketplace...".to_string(), + /*note*/ None, + )), + items: vec![SelectionItem { + name: "Adding marketplace...".to_string(), + description: Some( + "This updates when marketplace installation completes.".to_string(), + ), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_remove_confirmation_popup_params( + &self, + plugins_response: &PluginListResponse, + marketplace_name: String, + marketplace_display_name: String, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Remove {marketplace_display_name} marketplace?").dim(), + )); + header.push(Line::from( + "This removes the configured marketplace from Codex.".dim(), + )); + + let cwd_for_remove = self.config.cwd.to_path_buf(); + let cwd_for_cancel = self.config.cwd.to_path_buf(); + let cwd_for_on_cancel = self.config.cwd.to_path_buf(); + let plugins_response_for_cancel = plugins_response.clone(); + let plugins_response_for_on_cancel = plugins_response.clone(); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(Line::from(vec![ + Span::from(key_hint::plain(KeyCode::Enter)), + " select".dim(), + " · ".into(), + "esc close".dim(), + ])), + items: vec![ + SelectionItem { + name: "Remove marketplace".to_string(), + description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + selected_description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveLoading { + marketplace_display_name: marketplace_display_name.clone(), + }); + tx.send(AppEvent::FetchMarketplaceRemove { + cwd: cwd_for_remove.clone(), + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..Default::default() + }, + SelectionItem { + name: "Back to plugins".to_string(), + 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::OpenPluginsList { + cwd: cwd_for_cancel.clone(), + response: plugins_response_for_cancel.clone(), + }); + })], + ..Default::default() + }, + ], + on_cancel: Some(Box::new(move |tx| { + tx.send(AppEvent::OpenPluginsList { + cwd: cwd_for_on_cancel.clone(), + response: plugins_response_for_on_cancel.clone(), + }); + })), + ..Default::default() + } + } + + pub(super) fn marketplace_remove_loading_popup_params( + &self, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Removing {marketplace_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Removing marketplace...".to_string(), + description: Some("This updates when marketplace removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_upgrade_loading_popup_params( + &self, + marketplace_name: Option<&str>, + ) -> SelectionViewParams { + let loading_text = marketplace_name + .map(|name| format!("Upgrading {name} marketplace...")) + .unwrap_or_else(|| "Upgrading marketplaces...".to_string()); + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + loading_text.clone(), + /*note*/ None, + )), + items: vec![SelectionItem { + name: loading_text, + description: Some("This updates when marketplace upgrade completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_detail_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + format!("Loading details for {plugin_display_name}..."), + /*note*/ None, + )), + items: vec![SelectionItem { + name: "Loading plugin details...".to_string(), + description: Some("This updates when plugin details load.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_install_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Installing {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Installing plugin...".to_string(), + description: Some("This updates when plugin installation completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_uninstall_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Uninstalling {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Uninstalling plugin...".to_string(), + description: Some("This updates when the plugin removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to load plugins.".dim())); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Plugin marketplace unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_add_error_popup_params(&self) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to add marketplace.".dim())); + + let mut items = vec![ + SelectionItem { + name: "Marketplace add failed".to_string(), + description: Some( + "Failed to add marketplace from the provided source.".to_string(), + ), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Enter a marketplace source.".to_string()), + selected_description: Some("Enter a marketplace source.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + 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::OpenPluginsList { + cwd: cwd.clone(), + response: plugins_response.clone(), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn marketplace_remove_error_popup_params( + &self, + marketplace_name: &str, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to remove marketplace.".dim())); + + let marketplace_name = marketplace_name.to_string(); + let marketplace_display_name = marketplace_display_name.to_string(); + let mut items = vec![ + SelectionItem { + name: "Marketplace removal failed".to_string(), + description: Some("Failed to remove the selected marketplace.".to_string()), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Review the confirmation prompt again.".to_string()), + selected_description: Some("Review the confirmation prompt again.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveConfirm { + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + 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::OpenPluginsList { + cwd: cwd.clone(), + response: plugins_response.clone(), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn plugin_detail_error_popup_params( + &self, + err: &str, + plugins_response: Option<&PluginListResponse>, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to load plugin details.".dim())); + + let mut items = vec![SelectionItem { + name: "Plugin detail unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }]; + if let Some(plugins_response) = plugins_response.cloned() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + 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::OpenPluginsList { + cwd: cwd.clone(), + response: plugins_response.clone(), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn plugins_popup_params( + &self, + response: &PluginListResponse, + active_tab_id: Option, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let marketplaces = &response.marketplaces; + let preferred_local_sources = preferred_local_plugin_sources(marketplaces); + + let all_entries = plugin_entries_for_marketplaces(marketplaces); + let total = all_entries.len(); + let installed = all_entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .count(); + let name_column_width = all_entries + .iter() + .map(|(_, _, display_name)| { + PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) + }) + .chain([UnicodeWidthStr::width("Add marketplace")]) + .max(); + let installed_entries = all_entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .cloned() + .collect(); + + 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(), + header: plugins_header( + "Browse plugins from available marketplaces.".to_string(), + format!("Installed {installed} of {total} available plugins."), + ), + items: all_items, + }); + + tabs.push(SelectionTab { + id: INSTALLED_PLUGINS_TAB_ID.to_string(), + label: format!("Installed ({installed})"), + header: plugins_header( + "Installed plugins.".to_string(), + format!("Showing {installed} installed plugins."), + ), + items: self.plugin_selection_items( + installed_entries, + &preferred_local_sources, + /*include_marketplace_names*/ true, + "No installed plugins", + "No installed plugins.", + ), + }); + + 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(), + header: plugins_header( + "OpenAI Curated marketplace.".to_string(), + format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), + ), + items: curated_items, + }); + + let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces + .iter() + .filter(|marketplace| !MarketplaceProduct::from_marketplace(marketplace).is_by_openai()) + .collect(); + 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() + .map(|marketplace| marketplace_display_name(marketplace)) + .collect(), + ); + for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) { + let entries = plugin_entries_for_marketplaces([marketplace]); + let marketplace_total = entries.len(); + let marketplace_installed = entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .count(); + let tab_id = marketplace_tab_id(marketplace); + let can_remove_marketplace = + marketplace_is_user_configured(&self.config, &marketplace.name); + let can_upgrade_marketplace = marketplace.path.is_some() + && marketplace_is_user_configured_git(&self.config, &marketplace.name); + if can_remove_marketplace || can_upgrade_marketplace { + tab_footer_hints.push(( + tab_id.clone(), + plugins_popup_hint_line( + /*can_remove_marketplace*/ can_remove_marketplace, + /*can_upgrade_marketplace*/ can_upgrade_marketplace, + ), + )); + } + let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { + plugins_header( + format!("{label} installed successfully."), + "Select the plugins you want to use and press Enter to install or view details." + .to_string(), + ) + } else { + plugins_header( + format!("{label}."), + format!( + "Installed {marketplace_installed} of {marketplace_total} {label} plugins." + ), + ) + }; + 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), + header: Box::new(()), + footer_hint: Some(plugins_popup_hint_line( + /*can_remove_marketplace*/ false, /*can_upgrade_marketplace*/ false, + )), + tab_footer_hints, + tabs, + initial_tab_id, + is_searchable: true, + search_placeholder: Some("Type to search plugins".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + row_display: SelectionRowDisplay::SingleLine, + name_column_width, + initial_selected_idx, + ..Default::default() + } + } + + fn marketplace_add_tab(&self) -> SelectionTab { + SelectionTab { + id: ADD_MARKETPLACE_TAB_ID.to_string(), + label: "Add Marketplace".to_string(), + header: plugins_header( + "Add a marketplace from a Git repo or local root.".to_string(), + "Enter a source to make its plugins available in this menu.".to_string(), + ), + items: vec![SelectionItem { + name: "Add marketplace".to_string(), + description: Some( + "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), + ), + selected_description: Some( + "Press Enter to enter a marketplace source.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }], + } + } + + pub(super) fn plugin_detail_popup_params( + &self, + plugins_response: &PluginListResponse, + plugin: &PluginDetail, + ) -> SelectionViewParams { + 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 = plugin_detail_status_label(&plugin.summary); + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), + )); + if !plugin.summary.installed { + header.push(PluginDisclosureLine { + line: Line::from(vec![ + "Data shared with this app is subject to the app's ".into(), + "terms of service".bold(), + " and ".into(), + "privacy policy".bold(), + ". ".into(), + "Learn more".cyan().underlined(), + ".".into(), + ]), + }); + } + if let Some(description) = plugin_detail_description(plugin) { + header.push(Line::from(description.dim())); + } + + let cwd = self.config.cwd.to_path_buf(); + let plugins_response = plugins_response.clone(); + let mut items = vec![SelectionItem { + name: "Back to plugins".to_string(), + 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::OpenPluginsList { + cwd: cwd.clone(), + response: plugins_response.clone(), + }); + })], + ..Default::default() + }]; + + if plugin.summary.installed { + if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) { + let uninstall_cwd = self.config.cwd.to_path_buf(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some( + "This remote plugin did not provide an uninstall identity.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + } else if plugin.summary.availability == PluginAvailability::DisabledByAdmin { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("This plugin is disabled by your workspace admin.".to_string()), + is_disabled: true, + ..Default::default() + }); + } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some( + "This plugin is not installable from this marketplace.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else if let Some(location) = plugin_detail_location(plugin) { + let install_cwd = self.config.cwd.to_path_buf(); + let plugin_name = plugin_request_name(&plugin.summary); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Install this plugin now.".to_string()), + selected_description: Some("Install this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginInstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginInstall { + cwd: install_cwd.clone(), + location: location.clone(), + plugin_name: plugin_name.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("This plugin did not provide an install location.".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + items.extend(plugin_metadata_items(plugin)); + + items.push(SelectionItem { + name: "Skills".to_string(), + description: Some(plugin_skill_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Hooks".to_string(), + description: Some(plugin_hook_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Apps".to_string(), + description: Some(plugin_app_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "MCP Servers".to_string(), + description: Some(plugin_mcp_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + } + } + + 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, + ) -> Vec { + sort_plugin_entries(&mut plugin_entries); + let status_label_width = plugin_entries + .iter() + .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) + .max() + .unwrap_or(0); + + let mut items: Vec = Vec::new(); + for (marketplace, plugin, display_name) in plugin_entries { + let marketplace_label = marketplace_display_name(marketplace); + let status_label = plugin_status_label(plugin); + let description = if include_marketplace_names { + plugin_brief_description(plugin, &marketplace_label, status_label_width) + } else { + plugin_brief_description_without_marketplace(plugin, status_label_width) + }; + 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; + let selected_status_label = format!("{status_label: = + if let Some((location, plugin_name)) = plugin_detail_request { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginDetailLoading { + plugin_display_name: plugin_display_name.clone(), + }); + let (marketplace_path, remote_marketplace_name) = + location.clone().into_request_params(); + tx.send(AppEvent::FetchPluginDetail { + cwd: cwd.clone(), + params: codex_app_server_protocol::PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name: plugin_name.clone(), + }, + }); + })] + } else { + Vec::new() + }; + let is_disabled = !can_view_details && !plugin.installed; + let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string()); + + items.push(SelectionItem { + name: display_name, + toggle, + toggle_placeholder: (!can_toggle_plugin).then_some("[-] "), + description: Some(description), + selected_description: Some(selected_description), + search_value: Some(search_value), + actions, + is_disabled, + disabled_reason, + ..Default::default() + }); + } + + if items.is_empty() { + items.push(SelectionItem { + name: empty_name.to_string(), + description: Some(empty_description.to_string()), + is_disabled: true, + ..Default::default() + }); + } + items + } +} + +fn plugins_popup_hint_line( + can_remove_marketplace: bool, + can_upgrade_marketplace: bool, +) -> Line<'static> { + match (can_remove_marketplace, can_upgrade_marketplace) { + (true, true) => Line::from( + "ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close", + ), + (true, false) => { + Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close") + } + (false, true) => { + Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close") + } + (false, false) => Line::from( + "space enable/disable · ←/→ select marketplace · enter view details · esc close", + ), + } +} + +pub(super) fn plugin_detail_hint_line() -> Line<'static> { + Line::from("Press esc to close.") +} + +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())); + header.push(Line::from(count_line.dim())); + 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)> { + let entries = marketplaces + .into_iter() + .flat_map(|marketplace| { + marketplace + .plugins + .iter() + .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) + }) + .collect::>(); + dedupe_plugin_entries(entries) +} + +fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { + entries.sort_by(|left, right| { + right + .1 + .installed + .cmp(&left.1.installed) + .then_with(|| { + left.2 + .to_ascii_lowercase() + .cmp(&right.2.to_ascii_lowercase()) + }) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.1.name.cmp(&right.1.name)) + .then_with(|| left.1.id.cmp(&right.1.id)) + }); +} + +pub(super) fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { + match marketplace.path.as_ref() { + Some(path) => marketplace_tab_id_from_path(path.as_path()), + None => format!("marketplace:{}", marketplace.name), + } +} + +pub(super) fn marketplace_tab_id_from_path(path: &Path) -> String { + format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) +} + +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) + }) { + return Some(tab_id); + } + + let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; + if root.is_empty() { + return None; + } + let root = Path::new(root); + marketplaces.iter().find_map(|marketplace| { + marketplace + .path + .as_ref() + .is_some_and(|path| path.as_path().starts_with(root)) + .then(|| marketplace_tab_id(marketplace)) + }) +} + +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, +) { + let remote_names = 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_SECTIONS + .into_iter() + .any(|section| section.contains_marketplace(&marketplace.name)) + && !remote_names.contains(marketplace.name.as_str()) + }); + response.marketplaces.extend(remote_marketplaces); +} + +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 = HashMap::new(); + for label in &labels { + *counts.entry(label.clone()).or_insert(0) += 1; + } + + let mut seen = HashMap::new(); + labels + .into_iter() + .map(|label| { + let total = counts[&label]; + if total == 1 { + return label; + } + + 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() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| marketplace.name.clone()) +} + +pub(super) fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool { + let Some(user_config) = config.config_layer_stack.effective_user_config() else { + return false; + }; + user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + .is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name)) +} + +pub(super) fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool { + config + .config_layer_stack + .get_active_user_layer() + .and_then(|user_layer| user_layer.config.get("marketplaces")) + .and_then(toml::Value::as_table) + .and_then(|marketplaces| marketplaces.get(marketplace_name)) + .and_then(toml::Value::as_table) + .and_then(|marketplace| marketplace.get("source_type")) + .and_then(toml::Value::as_str) + .is_some_and(|source_type| source_type == "git") +} + +fn plugin_display_name(plugin: &PluginSummary) -> String { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.name.clone()) +} + +fn plugin_brief_description( + plugin: &PluginSummary, + marketplace_label: &str, + status_label_width: usize, +) -> String { + let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), + None => format!("{status_label} · {marketplace_label}"), + } +} + +fn plugin_brief_description_without_marketplace( + plugin: &PluginSummary, + status_label_width: usize, +) -> String { + let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {description}"), + None => status_label, + } +} + +fn plugin_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 => "Available", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + } +} + +fn plugin_location_for_marketplace( + marketplace: &PluginMarketplaceEntry, + plugin: &PluginSummary, +) -> Option { + if let Some(marketplace_path) = marketplace.path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(plugin).map(|_| PluginLocation::Remote { + marketplace_name: marketplace.name.clone(), + }) +} + +fn plugin_detail_location(plugin: &PluginDetail) -> Option { + if let Some(marketplace_path) = plugin.marketplace_path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote { + marketplace_name: plugin.marketplace_name.clone(), + }) +} + +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))) +} + +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.to_string(); + } + plugin.name.clone() +} + +fn plugin_remote_identity(plugin: &PluginSummary) -> Option<&str> { + plugin + .share_context + .as_ref() + .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).map(str::to_string); + } + Some(plugin.id.clone()) +} + +fn plugin_description(plugin: &PluginSummary) -> Option { + plugin + .interface + .as_ref() + .and_then(|interface| { + interface + .short_description + .as_deref() + .or(interface.long_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} + +fn plugin_detail_description(plugin: &PluginDetail) -> Option { + plugin + .description + .as_deref() + .or_else(|| { + plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.long_description.as_deref()) + }) + .or_else(|| { + plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} + +fn plugin_skill_summary(plugin: &PluginDetail) -> String { + if plugin.skills.is_empty() { + "No plugin skills.".to_string() + } else { + plugin + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>() + .join(", ") + } +} + +fn plugin_app_summary(plugin: &PluginDetail) -> String { + if plugin.apps.is_empty() { + "No plugin apps.".to_string() + } else { + plugin + .apps + .iter() + .map(|app| app.name.as_str()) + .collect::>() + .join(", ") + } +} + +fn plugin_hook_summary(plugin: &PluginDetail) -> String { + if plugin.hooks.is_empty() { + "No plugin hooks.".to_string() + } else { + let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new(); + for hook in &plugin.hooks { + if let Some((_, handler_count)) = event_counts + .iter_mut() + .find(|(event_name, _)| *event_name == hook.event_name) + { + *handler_count += 1; + } else { + event_counts.push((hook.event_name, 1)); + } + } + event_counts + .into_iter() + .map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})")) + .collect::>() + .join(", ") + } +} + +fn plugin_mcp_summary(plugin: &PluginDetail) -> String { + if plugin.mcp_servers.is_empty() { + "No plugin MCP servers.".to_string() + } else { + plugin.mcp_servers.join(", ") + } +} diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 5e091b48a380..0ff8333ffcf4 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1,76 +1,48 @@ -use std::path::Path; use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; use super::ChatWidget; +use super::plugin_catalog::marketplace_display_name; +use super::plugin_catalog::marketplace_is_user_configured; +use super::plugin_catalog::marketplace_is_user_configured_git; +use super::plugin_catalog::marketplace_tab_id; +use super::plugin_catalog::marketplace_tab_id_from_path; +use super::plugin_catalog::marketplace_tab_id_matching_saved_id; +use super::plugin_catalog::merge_remote_marketplaces; +use super::plugin_catalog::plugin_detail_hint_line; 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; -use crate::bottom_pane::SelectionRowDisplay; -use crate::bottom_pane::SelectionTab; -use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; use crate::key_hint; -use crate::legacy_core::config::Config; -use crate::motion::MotionMode; -use crate::motion::shimmer_text; -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::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::PluginAvailability; -use codex_app_server_protocol::PluginDetail; -use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; -use codex_app_server_protocol::PluginSource; -use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; -use codex_core_plugins::is_openai_curated_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_features::Feature; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use unicode_width::UnicodeWidthStr; - -const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; -const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; -const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; -const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; -const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; -const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; -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); + +pub(super) const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; +pub(super) const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; +pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; #[derive(Debug, Clone, Default)] pub(super) struct PluginListFetchState { pub(super) cache_cwd: Option, pub(super) in_flight_cwd: Option, + pub(super) vertical_section_requested: bool, } #[derive(Debug, Clone)] @@ -79,92 +51,6 @@ pub(super) struct PluginInstallAuthFlowState { next_app_index: usize, } -struct DelayedLoadingHeader { - started_at: Instant, - frame_requester: FrameRequester, - animations_enabled: bool, - loading_text: String, - note: Option, -} - -impl DelayedLoadingHeader { - fn new( - frame_requester: FrameRequester, - animations_enabled: bool, - loading_text: String, - note: Option, - ) -> Self { - Self { - started_at: Instant::now(), - frame_requester, - animations_enabled, - loading_text, - note, - } - } -} - -impl Renderable for DelayedLoadingHeader { - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - - let mut lines = Vec::with_capacity(3); - lines.push(Line::from("Plugins".bold())); - - let now = Instant::now(); - let elapsed = now.saturating_duration_since(self.started_at); - if elapsed < LOADING_ANIMATION_DELAY { - self.frame_requester - .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); - lines.push(Line::from(self.loading_text.as_str().dim())); - } else if self.animations_enabled { - self.frame_requester - .schedule_frame_in(LOADING_ANIMATION_INTERVAL); - lines.push(Line::from(shimmer_text( - self.loading_text.as_str(), - MotionMode::Animated, - ))); - } else { - lines.push(Line::from(self.loading_text.as_str().dim())); - } - - if let Some(note) = &self.note { - lines.push(Line::from(note.as_str().dim())); - } - - Paragraph::new(lines).render_ref(area, buf); - } - - fn desired_height(&self, _width: u16) -> u16 { - 2 + u16::from(self.note.is_some()) - } -} - -const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; - -struct PluginDisclosureLine { - line: Line<'static>, -} - -impl Renderable for PluginDisclosureLine { - fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(self.line.clone()) - .wrap(Wrap { trim: false }) - .render(area, buf); - mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); - } - - fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(self.line.clone()) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(u16::MAX) - } -} - #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] @@ -262,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()); @@ -290,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()) => @@ -327,12 +215,14 @@ 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; } } - fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { + pub(super) fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { @@ -360,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(); @@ -1095,1283 +1015,4 @@ impl ChatWidget { self.plugins_popup_params(response, active_tab_id, selected_idx), ); } - - fn plugins_loading_popup_params(&self) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - "Loading available plugins...".to_string(), - Some("This updates when the marketplace list is ready.".to_string()), - )), - items: vec![SelectionItem { - name: "Loading plugins...".to_string(), - description: Some("This updates when the marketplace list is ready.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - "Adding marketplace...".to_string(), - /*note*/ None, - )), - items: vec![SelectionItem { - name: "Adding marketplace...".to_string(), - description: Some( - "This updates when marketplace installation completes.".to_string(), - ), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_remove_confirmation_popup_params( - &self, - plugins_response: &PluginListResponse, - marketplace_name: String, - marketplace_display_name: String, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Remove {marketplace_display_name} marketplace?").dim(), - )); - header.push(Line::from( - "This removes the configured marketplace from Codex.".dim(), - )); - - let cwd_for_remove = self.config.cwd.to_path_buf(); - let cwd_for_cancel = self.config.cwd.to_path_buf(); - let cwd_for_on_cancel = self.config.cwd.to_path_buf(); - let plugins_response_for_cancel = plugins_response.clone(); - let plugins_response_for_on_cancel = plugins_response.clone(); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(Line::from(vec![ - Span::from(key_hint::plain(KeyCode::Enter)), - " select".dim(), - " · ".into(), - "esc close".dim(), - ])), - items: vec![ - SelectionItem { - name: "Remove marketplace".to_string(), - description: Some( - "Remove this marketplace from the available plugin list.".to_string(), - ), - selected_description: Some( - "Remove this marketplace from the available plugin list.".to_string(), - ), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenMarketplaceRemoveLoading { - marketplace_display_name: marketplace_display_name.clone(), - }); - tx.send(AppEvent::FetchMarketplaceRemove { - cwd: cwd_for_remove.clone(), - marketplace_name: marketplace_name.clone(), - marketplace_display_name: marketplace_display_name.clone(), - }); - })], - ..Default::default() - }, - SelectionItem { - name: "Back to plugins".to_string(), - 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 { - cwd: cwd_for_cancel.clone(), - result: Ok(plugins_response_for_cancel.clone()), - }); - })], - ..Default::default() - }, - ], - on_cancel: Some(Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd_for_on_cancel.clone(), - result: Ok(plugins_response_for_on_cancel.clone()), - }); - })), - ..Default::default() - } - } - - fn marketplace_remove_loading_popup_params( - &self, - marketplace_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Removing {marketplace_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Removing marketplace...".to_string(), - description: Some("This updates when marketplace removal completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_upgrade_loading_popup_params( - &self, - marketplace_name: Option<&str>, - ) -> SelectionViewParams { - let loading_text = marketplace_name - .map(|name| format!("Upgrading {name} marketplace...")) - .unwrap_or_else(|| "Upgrading marketplaces...".to_string()); - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - loading_text.clone(), - /*note*/ None, - )), - items: vec![SelectionItem { - name: loading_text, - description: Some("This updates when marketplace upgrade completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - format!("Loading details for {plugin_display_name}..."), - /*note*/ None, - )), - items: vec![SelectionItem { - name: "Loading plugin details...".to_string(), - description: Some("This updates when plugin details load.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_install_loading_popup_params( - &self, - plugin_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Installing {plugin_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Installing plugin...".to_string(), - description: Some("This updates when plugin installation completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_uninstall_loading_popup_params( - &self, - plugin_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Uninstalling {plugin_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Uninstalling plugin...".to_string(), - description: Some("This updates when the plugin removal completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to load plugins.".dim())); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Plugin marketplace unavailable".to_string(), - description: Some(err.to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_add_error_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to add marketplace.".dim())); - - let mut items = vec![ - SelectionItem { - name: "Marketplace add failed".to_string(), - description: Some( - "Failed to add marketplace from the provided source.".to_string(), - ), - is_disabled: true, - ..Default::default() - }, - SelectionItem { - name: "Try again".to_string(), - description: Some("Enter a marketplace source.".to_string()), - selected_description: Some("Enter a marketplace source.".to_string()), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenMarketplaceAddPrompt); - })], - ..Default::default() - }, - ]; - - if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - 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 { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn marketplace_remove_error_popup_params( - &self, - marketplace_name: &str, - marketplace_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to remove marketplace.".dim())); - - let marketplace_name = marketplace_name.to_string(); - let marketplace_display_name = marketplace_display_name.to_string(); - let mut items = vec![ - SelectionItem { - name: "Marketplace removal failed".to_string(), - description: Some("Failed to remove the selected marketplace.".to_string()), - is_disabled: true, - ..Default::default() - }, - SelectionItem { - name: "Try again".to_string(), - description: Some("Review the confirmation prompt again.".to_string()), - selected_description: Some("Review the confirmation prompt again.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenMarketplaceRemoveConfirm { - marketplace_name: marketplace_name.clone(), - marketplace_display_name: marketplace_display_name.clone(), - }); - })], - ..Default::default() - }, - ]; - - if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - 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 { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn plugin_detail_error_popup_params( - &self, - err: &str, - plugins_response: Option<&PluginListResponse>, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to load plugin details.".dim())); - - let mut items = vec![SelectionItem { - name: "Plugin detail unavailable".to_string(), - description: Some(err.to_string()), - is_disabled: true, - ..Default::default() - }]; - if let Some(plugins_response) = plugins_response.cloned() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - 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 { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn plugins_popup_params( - &self, - response: &PluginListResponse, - active_tab_id: Option, - initial_selected_idx: Option, - ) -> SelectionViewParams { - let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); - - let total: usize = marketplaces - .iter() - .map(|marketplace| marketplace.plugins.len()) - .sum(); - let installed = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .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)| { - PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) - }) - .chain([UnicodeWidthStr::width("Add marketplace")]) - .max(); - let installed_entries = all_entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .cloned() - .collect(); - - let mut tabs = Vec::new(); - let mut tab_footer_hints = Vec::new(); - tabs.push(SelectionTab { - id: ALL_PLUGINS_TAB_ID.to_string(), - label: "All Plugins".to_string(), - header: plugins_header( - "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.", - ), - }); - - tabs.push(SelectionTab { - id: INSTALLED_PLUGINS_TAB_ID.to_string(), - label: format!("Installed ({installed})"), - header: plugins_header( - "Installed plugins.".to_string(), - format!("Showing {installed} installed plugins."), - ), - items: self.plugin_selection_items( - installed_entries, - /*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_total = curated_entries.len(); - let curated_installed = curated_entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .count(); - tabs.push(SelectionTab { - id: OPENAI_CURATED_TAB_ID.to_string(), - label: "OpenAI Curated".to_string(), - header: plugins_header( - "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.", - ), - }); - - let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces - .iter() - .copied() - .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) - .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)) - }); - - let labels = disambiguate_duplicate_tab_labels( - additional_marketplaces - .iter() - .map(|marketplace| marketplace_display_name(marketplace)) - .collect(), - ); - for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) { - let entries = plugin_entries_for_marketplaces([marketplace]); - let marketplace_total = entries.len(); - let marketplace_installed = entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .count(); - let tab_id = marketplace_tab_id(marketplace); - let can_remove_marketplace = - marketplace_is_user_configured(&self.config, &marketplace.name); - let can_upgrade_marketplace = marketplace.path.is_some() - && marketplace_is_user_configured_git(&self.config, &marketplace.name); - if can_remove_marketplace || can_upgrade_marketplace { - tab_footer_hints.push(( - tab_id.clone(), - plugins_popup_hint_line( - /*can_remove_marketplace*/ can_remove_marketplace, - /*can_upgrade_marketplace*/ can_upgrade_marketplace, - ), - )); - } - let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { - plugins_header( - format!("{label} installed successfully."), - "Select the plugins you want to use and press Enter to install or view details." - .to_string(), - ) - } else { - plugins_header( - format!("{label}."), - format!( - "Installed {marketplace_installed} of {marketplace_total} {label} plugins." - ), - ) - }; - 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.", - ), - }); - } - - tabs.push(self.marketplace_add_tab()); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(()), - footer_hint: Some(plugins_popup_hint_line( - /*can_remove_marketplace*/ false, /*can_upgrade_marketplace*/ false, - )), - tab_footer_hints, - tabs, - initial_tab_id: active_tab_id, - is_searchable: true, - search_placeholder: Some("Type to search plugins".to_string()), - col_width_mode: ColumnWidthMode::AutoAllRows, - row_display: SelectionRowDisplay::SingleLine, - name_column_width, - initial_selected_idx, - ..Default::default() - } - } - - fn marketplace_add_tab(&self) -> SelectionTab { - SelectionTab { - id: ADD_MARKETPLACE_TAB_ID.to_string(), - label: "Add Marketplace".to_string(), - header: plugins_header( - "Add a marketplace from a Git repo or local root.".to_string(), - "Enter a source to make its plugins available in this menu.".to_string(), - ), - items: vec![SelectionItem { - name: "Add marketplace".to_string(), - description: Some( - "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), - ), - selected_description: Some( - "Press Enter to enter a marketplace source.".to_string(), - ), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenMarketplaceAddPrompt); - })], - ..Default::default() - }], - } - } - - fn plugin_detail_popup_params( - &self, - plugins_response: &PluginListResponse, - plugin: &PluginDetail, - ) -> SelectionViewParams { - let marketplace_label = 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 mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), - )); - if !plugin.summary.installed { - header.push(PluginDisclosureLine { - line: Line::from(vec![ - "Data shared with this app is subject to the app's ".into(), - "terms of service".bold(), - " and ".into(), - "privacy policy".bold(), - ". ".into(), - "Learn more".cyan().underlined(), - ".".into(), - ]), - }); - } - if let Some(description) = plugin_detail_description(plugin) { - header.push(Line::from(description.dim())); - } - - let cwd = self.config.cwd.to_path_buf(); - let plugins_response = plugins_response.clone(); - let mut items = vec![SelectionItem { - name: "Back to plugins".to_string(), - 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 { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }]; - - if plugin.summary.installed { - if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) { - let uninstall_cwd = self.config.cwd.to_path_buf(); - let plugin_display_name = display_name; - items.push(SelectionItem { - name: "Uninstall plugin".to_string(), - description: Some("Remove this plugin now.".to_string()), - selected_description: Some("Remove this plugin now.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginUninstallLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginUninstall { - cwd: uninstall_cwd.clone(), - plugin_id: plugin_id.clone(), - plugin_display_name: plugin_display_name.clone(), - }); - })], - ..Default::default() - }); - } else { - items.push(SelectionItem { - name: "Uninstall plugin".to_string(), - description: Some( - "This remote plugin did not provide an uninstall identity.".to_string(), - ), - is_disabled: true, - ..Default::default() - }); - } - } else if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("This plugin is disabled by your workspace admin.".to_string()), - is_disabled: true, - ..Default::default() - }); - } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some( - "This plugin is not installable from this marketplace.".to_string(), - ), - is_disabled: true, - ..Default::default() - }); - } else if let Some(location) = plugin_detail_location(plugin) { - let install_cwd = self.config.cwd.to_path_buf(); - let plugin_name = plugin_request_name(&plugin.summary); - let plugin_display_name = display_name; - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("Install this plugin now.".to_string()), - selected_description: Some("Install this plugin now.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginInstallLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginInstall { - cwd: install_cwd.clone(), - location: location.clone(), - plugin_name: plugin_name.clone(), - plugin_display_name: plugin_display_name.clone(), - }); - })], - ..Default::default() - }); - } else { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("This plugin did not provide an install location.".to_string()), - is_disabled: true, - ..Default::default() - }); - } - - items.push(SelectionItem { - name: "Skills".to_string(), - description: Some(plugin_skill_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "Hooks".to_string(), - description: Some(plugin_hook_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "Apps".to_string(), - description: Some(plugin_app_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "MCP Servers".to_string(), - description: Some(plugin_mcp_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - col_width_mode: ColumnWidthMode::AutoAllRows, - ..Default::default() - } - } - - fn plugin_selection_items<'a>( - &self, - mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, - include_marketplace_names: bool, - empty_name: &str, - empty_description: &str, - ) -> Vec { - sort_plugin_entries(&mut plugin_entries); - let status_label_width = plugin_entries - .iter() - .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) - .max() - .unwrap_or(0); - - let mut items: Vec = Vec::new(); - for (marketplace, plugin, display_name) in plugin_entries { - let marketplace_label = marketplace_display_name(marketplace); - let status_label = plugin_status_label(plugin); - let description = if include_marketplace_names { - plugin_brief_description(plugin, &marketplace_label, status_label_width) - } else { - plugin_brief_description_without_marketplace(plugin, status_label_width) - }; - let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); - 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; - let selected_status_label = format!("{status_label: = - if let Some((location, plugin_name)) = plugin_detail_request { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginDetailLoading { - plugin_display_name: plugin_display_name.clone(), - }); - let (marketplace_path, remote_marketplace_name) = - location.clone().into_request_params(); - tx.send(AppEvent::FetchPluginDetail { - cwd: cwd.clone(), - params: codex_app_server_protocol::PluginReadParams { - marketplace_path, - remote_marketplace_name, - plugin_name: plugin_name.clone(), - }, - }); - })] - } else { - Vec::new() - }; - let is_disabled = !can_view_details && !plugin.installed; - let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string()); - - items.push(SelectionItem { - name: display_name, - toggle, - toggle_placeholder: (!can_toggle_plugin).then_some("[-] "), - description: Some(description), - selected_description: Some(selected_description), - search_value: Some(search_value), - actions, - is_disabled, - disabled_reason, - ..Default::default() - }); - } - - if items.is_empty() { - items.push(SelectionItem { - name: empty_name.to_string(), - description: Some(empty_description.to_string()), - is_disabled: true, - ..Default::default() - }); - } - items - } -} - -fn plugins_popup_hint_line( - can_remove_marketplace: bool, - can_upgrade_marketplace: bool, -) -> Line<'static> { - match (can_remove_marketplace, can_upgrade_marketplace) { - (true, true) => Line::from( - "ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close", - ), - (true, false) => { - Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close") - } - (false, true) => { - Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close") - } - (false, false) => Line::from( - "space enable/disable · ←/→ select marketplace · enter view details · esc close", - ), - } -} - -fn plugin_detail_hint_line() -> Line<'static> { - Line::from("Press esc to close.") -} - -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())); - header.push(Line::from(count_line.dim())); - Box::new(header) -} - -fn plugin_entries_for_marketplaces<'a>( - marketplaces: impl IntoIterator, -) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { - marketplaces - .into_iter() - .flat_map(|marketplace| { - marketplace - .plugins - .iter() - .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) - }) - .collect() -} - -fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { - entries.sort_by(|left, right| { - right - .1 - .installed - .cmp(&left.1.installed) - .then_with(|| { - left.2 - .to_ascii_lowercase() - .cmp(&right.2.to_ascii_lowercase()) - }) - .then_with(|| left.2.cmp(&right.2)) - .then_with(|| left.1.name.cmp(&right.1.name)) - .then_with(|| left.1.id.cmp(&right.1.id)) - }); -} - -fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - match marketplace.path.as_ref() { - Some(path) => marketplace_tab_id_from_path(path.as_path()), - None => format!("marketplace:{}", marketplace.name), - } -} - -fn marketplace_tab_id_from_path(path: &Path) -> String { - format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) -} - -fn marketplace_tab_id_matching_saved_id( - saved_tab_id: &str, - marketplaces: &[PluginMarketplaceEntry], -) -> Option { - 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) - }) { - return Some(tab_id); - } - - let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; - if root.is_empty() { - return None; - } - let root = Path::new(root); - marketplaces.iter().find_map(|marketplace| { - marketplace - .path - .as_ref() - .is_some_and(|path| path.as_path().starts_with(root)) - .then(|| marketplace_tab_id(marketplace)) - }) -} - -fn merge_remote_marketplaces( - response: &mut PluginListResponse, - remote_marketplaces: Vec, -) { - let remote_names = remote_marketplaces - .iter() - .map(|marketplace| marketplace.name.clone()) - .collect::>(); - response.marketplaces.retain(|marketplace| { - marketplace.path.is_some() - || !remote_marketplace_is_remote_section(marketplace) - && !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 disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { - let mut counts: Vec<(String, usize)> = Vec::new(); - for label in &labels { - if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { - *count += 1; - } else { - counts.push((label.clone(), 1)); - } - } - - let mut seen: Vec<(String, usize)> = Vec::new(); - labels - .into_iter() - .map(|label| { - let total = counts - .iter() - .find(|(existing, _)| existing == &label) - .map(|(_, count)| *count) - .unwrap_or(1); - 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 - }; - format!("{label} ({current}/{total})") - }) - .collect() -} - -fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { - marketplace - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| marketplace.name.clone()) -} - -fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool { - let Some(user_config) = config.config_layer_stack.effective_user_config() else { - return false; - }; - user_config - .get("marketplaces") - .and_then(toml::Value::as_table) - .is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name)) -} - -fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool { - config - .config_layer_stack - .get_active_user_layer() - .and_then(|user_layer| user_layer.config.get("marketplaces")) - .and_then(toml::Value::as_table) - .and_then(|marketplaces| marketplaces.get(marketplace_name)) - .and_then(toml::Value::as_table) - .and_then(|marketplace| marketplace.get("source_type")) - .and_then(toml::Value::as_str) - .is_some_and(|source_type| source_type == "git") -} - -fn plugin_display_name(plugin: &PluginSummary) -> String { - plugin - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| plugin.name.clone()) -} - -fn plugin_brief_description( - plugin: &PluginSummary, - marketplace_label: &str, - status_label_width: usize, -) -> String { - let status_label = plugin_status_label(plugin); - let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), - None => format!("{status_label} · {marketplace_label}"), - } -} - -fn plugin_brief_description_without_marketplace( - plugin: &PluginSummary, - status_label_width: usize, -) -> String { - let status_label = plugin_status_label(plugin); - let status_label = format!("{status_label: format!("{status_label} · {description}"), - None => status_label, - } -} - -fn plugin_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 => "Available", - PluginInstallPolicy::InstalledByDefault => "Available", - } - } -} - -fn plugin_location_for_marketplace( - marketplace: &PluginMarketplaceEntry, - plugin: &PluginSummary, -) -> Option { - if let Some(marketplace_path) = marketplace.path.clone() { - return Some(PluginLocation::Local { marketplace_path }); - } - plugin_remote_identity(plugin).map(|_| PluginLocation::Remote { - marketplace_name: marketplace.name.clone(), - }) -} - -fn plugin_detail_location(plugin: &PluginDetail) -> Option { - if let Some(marketplace_path) = plugin.marketplace_path.clone() { - return Some(PluginLocation::Local { marketplace_path }); - } - plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote { - marketplace_name: plugin.marketplace_name.clone(), - }) -} - -fn plugin_detail_request_for_entry( - marketplace: &PluginMarketplaceEntry, - plugin: &PluginSummary, -) -> Option<(PluginLocation, String)> { - plugin_location_for_marketplace(marketplace, plugin) - .map(|location| (location, plugin_request_name(plugin))) -} - -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; - } - plugin.name.clone() -} - -fn plugin_remote_identity(plugin: &PluginSummary) -> Option { - plugin - .share_context - .as_ref() - .map(|context| context.remote_plugin_id.clone()) - .or_else(|| plugin.remote_plugin_id.clone()) -} - -fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { - if matches!(&plugin.source, PluginSource::Remote) { - return plugin_remote_identity(plugin); - } - Some(plugin.id.clone()) -} - -fn plugin_description(plugin: &PluginSummary) -> Option { - plugin - .interface - .as_ref() - .and_then(|interface| { - interface - .short_description - .as_deref() - .or(interface.long_description.as_deref()) - }) - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) -} - -fn plugin_detail_description(plugin: &PluginDetail) -> Option { - plugin - .description - .as_deref() - .or_else(|| { - plugin - .summary - .interface - .as_ref() - .and_then(|interface| interface.long_description.as_deref()) - }) - .or_else(|| { - plugin - .summary - .interface - .as_ref() - .and_then(|interface| interface.short_description.as_deref()) - }) - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) -} - -fn plugin_skill_summary(plugin: &PluginDetail) -> String { - if plugin.skills.is_empty() { - "No plugin skills.".to_string() - } else { - plugin - .skills - .iter() - .map(|skill| skill.name.as_str()) - .collect::>() - .join(", ") - } -} - -fn plugin_app_summary(plugin: &PluginDetail) -> String { - if plugin.apps.is_empty() { - "No plugin apps.".to_string() - } else { - plugin - .apps - .iter() - .map(|app| app.name.as_str()) - .collect::>() - .join(", ") - } -} - -fn plugin_hook_summary(plugin: &PluginDetail) -> String { - if plugin.hooks.is_empty() { - "No plugin hooks.".to_string() - } else { - let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new(); - for hook in &plugin.hooks { - if let Some((_, handler_count)) = event_counts - .iter_mut() - .find(|(event_name, _)| *event_name == hook.event_name) - { - *handler_count += 1; - } else { - event_counts.push((hook.event_name, 1)); - } - } - event_counts - .into_iter() - .map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})")) - .collect::>() - .join(", ") - } -} - -fn plugin_mcp_summary(plugin: &PluginDetail) -> String { - if plugin.mcp_servers.is_empty() { - "No plugin MCP servers.".to_string() - } else { - plugin.mcp_servers.join(", ") - } } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 67e857e929fe..dfaddb33a9f7 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -212,8 +212,10 @@ impl ChatWidget { | ServerNotification::McpServerOauthLoginCompleted(_) | ServerNotification::AppListUpdated(_) | ServerNotification::RemoteControlStatusChanged(_) + | ServerNotification::ExternalAgentConfigImportProgress(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::ModelSafetyBufferingUpdated(_) | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs index 2f33c9809a84..5729c073fd65 100644 --- a/codex-rs/tui/src/chatwidget/protocol_requests.rs +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -46,6 +46,7 @@ impl ChatWidget { } ServerRequest::DynamicToolCall { .. } | ServerRequest::AttestationGenerate { .. } + | ServerRequest::CurrentTimeRead { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => { diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index efb0b636288e..77a0c5c7eceb 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/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 37af21a01241..710af6b7ea42 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -36,6 +36,16 @@ impl ChatWidget { })), ); } + if let Some(cell) = self.pending_rate_limit_reset_hint() { + flex.push( + /*flex*/ 1, + RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell, + top: 1, + right: active_cell_right_reserve, + })), + ); + } flex.push( /*flex*/ 0, RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable { diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 98f87fcb7220..32606a3bac97 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. @@ -220,12 +221,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(); - } + // 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; @@ -234,6 +250,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 d30f69b6e9a0..2c48b8f2222b 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -264,6 +264,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; @@ -271,7 +278,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() @@ -396,9 +403,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(); @@ -432,6 +441,7 @@ impl ChatWidget { } SlashCommand::Permissions => { self.open_permissions_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Vim => { self.toggle_vim_mode_and_notify(); @@ -573,8 +583,8 @@ impl ChatWidget { } } SlashCommand::Usage => { - if self.ensure_token_activity_command_available() { - self.add_token_activity_output(tokens::TokenActivityView::Daily); + if self.ensure_usage_command_available() { + self.open_usage_menu(); } } SlashCommand::Ide => { @@ -684,7 +694,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() @@ -797,7 +807,7 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { SlashCommand::Usage => { - if self.ensure_token_activity_command_available() { + if self.ensure_usage_command_available() { match tokens::TokenActivityView::parse(trimmed) { Some(view) => self.add_token_activity_output(view), None => self.add_error_message( @@ -1218,7 +1228,7 @@ impl ChatWidget { } } - fn ensure_token_activity_command_available(&mut self) -> bool { + fn ensure_usage_command_available(&mut self) -> bool { if self.has_codex_backend_auth { return true; } 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 e8f08a437acd..4b4a02e0be0b 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__esc_interrupt_goal_paused_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap new file mode 100644 index 000000000000..bebbb3ffd59a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: snapshot +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap new file mode 100644 index 000000000000..1025ae386295 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: snapshot +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " 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 c7edae171431..e5d14a4c51ca 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 4d91b17f34f5..2b69864cc5dc 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__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 40d26d3c9dfe..9f828ab3bc93 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 @@ -9,9 +9,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated 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 /plugi… + [-] 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 515b700925fa..803ff9fbe67c 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__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 dac63d4e3248..008669d3d3a5 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 000000000000..1d2a74e9565d --- /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 new file mode 100644 index 000000000000..56f0fc2f89c9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: rendered +--- +• 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 new file mode 100644 index 000000000000..e18178d460a8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expect(\"active output with reset hint\"),)" +--- +active tool + +• 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 new file mode 100644 index 000000000000..12b1568f55ad --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap @@ -0,0 +1,71 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "states.join(\"\\n---\\n\")" +--- + Usage limit resets + Checking your available resets... + +› Loading... + + Press enter to confirm or esc to go back +--- + Usage limit resets + You have 2 usage limit resets available. + + 1. Use a reset Reset your current 5-hour and weekly usage limits. +› 2. Cancel + + Press enter to confirm or esc to go back +--- + Usage limit resets + You don't have any usage limit resets available. + +› 1. Close + + Press enter to confirm or esc to go back +--- + Usage limit resets + Couldn't load usage limit resets. Please try again. + +› 1. Close + + Press enter to confirm or esc to go back +--- + Usage limit resets + Resetting your usage... + +› Using a reset... +--- + Usage limit resets + Couldn't reset usage. Please try again. + +› 1. Try again + 2. Close + + Press enter to confirm or esc to go back +--- + Usage limit resets + Your usage does not need a reset right now. + +› 1. Close + + Press enter to confirm or esc to go back +--- + Usage limit resets + No usage limit resets are available. + +› 1. Close + + Press enter to confirm or esc to go back +--- + Usage limit resets + Usage reset. Checking your remaining resets... + +› Refreshing... +--- + Usage limit resets + Usage reset. You have 1 usage limit reset left. + +› 1. Close + + Press enter to confirm or esc to go back 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 000000000000..32ad18ec6537 --- /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 new file mode 100644 index 000000000000..70aa225aafed --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +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 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 new file mode 100644 index 000000000000..d4dc5deff108 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +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 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 new file mode 100644 index 000000000000..127e37f7a347 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Usage + View account usage or redeem an earned reset. + +› 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 000000000000..08a595d4a284 --- /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 d4de7383140c..2c58be937086 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 592dd0438172..5f02dd8d690a 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/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index f6eaa8099338..897094b1f6d0 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -54,7 +54,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::StopCommitAnimation); } if had_stream_controller { - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion_after_stream_shutdown(); } } @@ -193,7 +193,7 @@ impl ChatWidget { if should_restore_after_stream { self.status_state.pending_status_indicator_restore = true; self.maybe_restore_status_indicator_after_stream_idle(); - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion_after_stream_shutdown(); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ba568aa819ff..b30d24343659 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -164,7 +164,7 @@ pub(super) use codex_terminal_detection::TerminalInfo; pub(super) use codex_terminal_detection::TerminalName; pub(super) use codex_utils_absolute_path::AbsolutePathBuf; pub(super) use codex_utils_approval_presets::builtin_approval_presets; -pub(super) use codex_utils_path_uri::ApiPathString; +pub(super) use codex_utils_path_uri::LegacyAppPathString; pub(super) use crossterm::event::KeyCode; pub(super) use crossterm::event::KeyEvent; pub(super) use crossterm::event::KeyModifiers; @@ -257,6 +257,7 @@ mod status_and_layout; mod status_command_tests; mod status_surface_previews; mod terminal_title; +mod usage; #[path = "tests/workflows__slash_commands.rs"] mod workflows_slash_commands; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index efc4843584d9..f0b1354a42e2 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -30,6 +30,7 @@ fn thread_settings_for_test( developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, } @@ -566,7 +567,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command: command.clone(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::InProgress, @@ -588,7 +589,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command, - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 69d75ddc6722..1ec9a9c38156 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -13,6 +13,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { call_id: "call-short".into(), approval_id: Some("call-short".into()), turn_id: "turn-short".into(), + environment_id: Some("remote".to_string()), command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -57,13 +58,14 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some( shlex::try_join(["/bin/zsh", "-lc", script]) .expect("round-trippable shell wrapper"), ), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, @@ -89,8 +91,8 @@ fn app_server_exec_approval_request_preserves_permissions_context() { .expect("absolute read path"); let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write"))) .expect("absolute write path"); - let read_api_path = ApiPathString::from_abs_path(&read_path); - let write_api_path = ApiPathString::from_abs_path(&write_path); + let read_api_path = LegacyAppPathString::from_abs_path(&read_path); + let write_api_path = LegacyAppPathString::from_abs_path(&write_path); let request = exec_approval_request_from_params( AppServerCommandExecutionRequestApprovalParams { thread_id: "thread-1".to_string(), @@ -98,13 +100,14 @@ fn app_server_exec_approval_request_preserves_permissions_context() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp, }), command: Some("ls".to_string()), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: Some(AppServerAdditionalPermissionProfile { network: Some(AppServerAdditionalNetworkPermissions { @@ -157,6 +160,7 @@ async fn network_exec_approval_history_describes_session_host_allowance() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), @@ -198,6 +202,7 @@ async fn network_exec_approval_history_describes_one_time_host_allowance() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), @@ -239,6 +244,7 @@ async fn network_exec_approval_history_describes_canceled_host_request() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), @@ -276,8 +282,8 @@ fn app_server_request_permissions_preserves_file_system_permissions() { .expect("absolute read path"); let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write"))) .expect("absolute write path"); - let read_api_path = ApiPathString::from_abs_path(&read_path); - let write_api_path = ApiPathString::from_abs_path(&write_path); + let read_api_path = LegacyAppPathString::from_abs_path(&read_path); + let write_api_path = LegacyAppPathString::from_abs_path(&write_path); let cwd = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp"))).expect("absolute cwd"); @@ -330,6 +336,7 @@ async fn exec_approval_uses_approval_id_when_present() { call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -372,6 +379,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-multi".into(), approval_id: Some("call-multi".into()), turn_id: "turn-multi".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -423,6 +431,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-long".into(), approval_id: Some("call-long".into()), turn_id: "turn-long".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), long], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index ca1c9bc6815a..4f7d225dd88b 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -10,6 +10,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { call_id: "call-short".into(), approval_id: Some("call-short".into()), turn_id: "turn-short".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -56,13 +57,14 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: Some("approval-1".to_string()), + environment_id: None, reason: None, network_approval_context: None, command: Some( shlex::try_join(["/bin/zsh", "-lc", script]) .expect("round-trippable shell wrapper"), ), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, @@ -93,6 +95,7 @@ async fn exec_approval_uses_approval_id_when_present() { call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -136,6 +139,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-multi".into(), approval_id: Some("call-multi".into()), turn_id: "turn-multi".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -189,6 +193,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { call_id: "call-long".into(), approval_id: Some("call-long".into()), turn_id: "turn-long".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), long], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, @@ -358,7 +363,7 @@ async fn exec_end_without_begin_uses_event_command() { AppServerThreadItem::CommandExecution { id: "call-orphan".to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd, + cwd: cwd.into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::Completed, @@ -1064,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); @@ -1098,6 +1103,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { call_id: "call-approve-cmd".into(), approval_id: Some("call-approve-cmd".into()), turn_id: "turn-approve-cmd".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some( @@ -1155,6 +1161,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { call_id: "call-approve-cmd-noreason".into(), approval_id: Some("call-approve-cmd-noreason".into()), turn_id: "turn-approve-cmd-noreason".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, @@ -1201,6 +1208,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() call_id: "call-approve-cmd-multiline-trunc".into(), approval_id: Some("call-approve-cmd-multiline-trunc".into()), turn_id: "turn-approve-cmd-multiline-trunc".into(), + environment_id: None, command: command.clone(), cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index b68716d80b27..b1d68ed371b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -836,7 +836,7 @@ pub(super) fn begin_exec_with_source( let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: chat.config.cwd.clone(), + cwd: chat.config.cwd.clone().into(), process_id: None, source, status: AppServerCommandExecutionStatus::InProgress, @@ -859,7 +859,7 @@ pub(super) fn begin_unified_exec_startup( let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: chat.config.cwd.clone(), + cwd: chat.config.cwd.clone().into(), process_id: Some(process_id.to_string()), source: ExecCommandSource::UnifiedExecStartup, status: AppServerCommandExecutionStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index e6b9d4ede024..f8626dfcc0ba 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -973,6 +973,7 @@ async fn replayed_in_progress_mcp_tool_call_stays_active() { tool: "copilot".to_string(), status: codex_app_server_protocol::McpToolCallStatus::InProgress, arguments: json!({"action": "wait"}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a9f6f7abe114..ebac59567148 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/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index a019a92d7fc6..145b8a42d804 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginShareContext; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -250,7 +251,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 @@ -459,11 +460,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"), @@ -584,10 +594,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 +614,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!( @@ -1022,6 +1040,222 @@ 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}" + ); + + 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; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 37f20ad970f4..d8b3f8a0cd79 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -83,7 +83,7 @@ fn dispatch_usage_and_expect_refresh( chat: &mut ChatWidget, rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> u64 { - chat.dispatch_command(SlashCommand::Usage); + chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new()); expect_token_activity_refresh(rx) } @@ -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()); } @@ -589,7 +626,9 @@ async fn ctrl_d_with_modal_open_does_not_quit() { #[tokio::test] async fn slash_init_does_not_depend_on_loaded_instruction_sources() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.instruction_source_paths = vec![chat.config.cwd.join("project-instructions.md")]; + chat.instruction_source_paths = vec![codex_utils_path_uri::PathUri::from_abs_path( + &chat.config.cwd.join("project-instructions.md"), + )]; submit_composer_text(&mut chat, "/init"); @@ -1245,7 +1284,7 @@ async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() { /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, ); - chat.dispatch_command(SlashCommand::Usage); + chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new()); assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. })); assert!(!chat.has_chatgpt_account()); @@ -1259,7 +1298,7 @@ async fn usage_command_runs_with_backend_auth_from_widget_init() { ) .await; - chat.dispatch_command(SlashCommand::Usage); + chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new()); assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. })); assert!(!chat.has_chatgpt_account()); @@ -1375,7 +1414,7 @@ async fn completed_token_activity_refresh_waits_for_active_stream() { let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); chat.on_agent_message_delta("partial response".to_string()); - assert!(chat.token_activity_history_insertion_blocked()); + assert!(chat.usage_history_insertion_blocked()); assert!( chat.finish_token_activity_refresh( @@ -1390,10 +1429,10 @@ async fn completed_token_activity_refresh_waits_for_active_stream() { ); chat.finalize_turn(); - assert!(!chat.token_activity_history_insertion_blocked()); + assert!(!chat.usage_history_insertion_blocked()); assert!( std::iter::from_fn(|| rx.try_recv().ok()) - .any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput)) + .any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown)) ); assert!(chat.take_completed_token_activity_output().is_some()); } @@ -1414,11 +1453,11 @@ async fn completed_token_activity_refresh_waits_for_queued_stream_consolidation( Err("token activity unavailable".to_string()), ) ); - assert!(chat.token_activity_history_insertion_blocked()); + assert!(chat.usage_history_insertion_blocked()); chat.note_stream_consolidation_completed(); - assert!(!chat.token_activity_history_insertion_blocked()); + assert!(!chat.usage_history_insertion_blocked()); } #[tokio::test] @@ -1436,15 +1475,12 @@ async fn completed_token_activity_refresh_waits_for_active_history_cell() { Err("token activity unavailable".to_string()), ) ); - assert!(chat.token_activity_history_insertion_blocked()); + assert!(chat.usage_history_insertion_blocked()); chat.flush_active_cell(); assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::CommitCompletedTokenActivityOutput) - ); + assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput)); } #[tokio::test] @@ -1469,7 +1505,7 @@ async fn completed_token_activity_refresh_waits_for_active_hook() { Err("token activity unavailable".to_string()), ) ); - assert!(chat.token_activity_history_insertion_blocked()); + assert!(chat.usage_history_insertion_blocked()); handle_hook_completed( &mut chat, @@ -1486,10 +1522,7 @@ async fn completed_token_activity_refresh_waits_for_active_hook() { ); assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::CommitCompletedTokenActivityOutput) - ); + assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput)); } #[tokio::test] @@ -1516,7 +1549,7 @@ async fn completed_token_activity_refresh_retries_after_plan_item_completion() { assert!( std::iter::from_fn(|| rx.try_recv().ok()) - .any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput)) + .any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown)) ); } @@ -1599,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 @@ -1608,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] @@ -2297,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); @@ -2354,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(), @@ -2591,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__approval_requests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap index 7e766d67eda0..fbb12551fde0 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap @@ -3,12 +3,14 @@ source: tui/src/chatwidget/tests/approval_requests.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 13 }, + area: Rect { x: 0, y: 0, width: 80, height: 15 }, content: [ " ", " ", " Would you like to run the following command? ", " ", + " Environment: remote ", + " ", " Reason: this is a test reason such as one that would be produced by the ", " model ", " ", @@ -23,17 +25,19 @@ Buffer { x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE, - x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE, - x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 15, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 10, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 73, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 7, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 9, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 9, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE, + x: 20, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 11, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, ] } 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 3a36265d262a..5578de9bd556 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -13,6 +13,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() { @@ -988,6 +997,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; @@ -1375,33 +1417,97 @@ async fn streaming_final_answer_keeps_task_running_state() { #[tokio::test] async fn ctrl_c_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_interrupt_op(&mut op_rx); + assert_goal_paused_event(&mut rx, thread_id); +} + +#[tokio::test] +async fn esc_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + + update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused); + let width = 80; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw goal paused footer"); + let snapshot = normalized_backend_snapshot(terminal.backend()); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); + }); + #[cfg(not(target_os = "windows"))] + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); +} + +#[tokio::test] +async fn request_user_input_interrupt_pauses_active_goal_turn() { + for key_event in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + chat.handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: Vec::new(), + auto_resolution_ms: None, + }); + + chat.handle_key_event(key_event); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + } +} + +fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); + update_thread_goal(chat, thread_id, AppThreadGoalStatus::Active); + chat.on_task_started(); + thread_id +} + +fn update_thread_goal(chat: &mut ChatWidget, thread_id: ThreadId, status: AppThreadGoalStatus) { let mut goal = test_thread_goal( - codex_app_server_protocol::ThreadGoalStatus::Active, + status, /*token_budget*/ Some(50_000), /*tokens_used*/ 40_000, ); - goal.thread_id = thread_id.to_string(); + let thread_id = thread_id.to_string(); + goal.thread_id = thread_id.clone(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), + thread_id, turn_id: None, goal, }, ), /*replay_kind*/ None, ); - chat.on_task_started(); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); +} - match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => {} - other => panic!("expected Op::Interrupt, got {other:?}"), - } +fn assert_goal_paused_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + thread_id: ThreadId, +) { assert_matches!( rx.try_recv(), Ok(AppEvent::SetThreadGoalStatus { @@ -1829,6 +1935,7 @@ async fn status_widget_and_approval_modal_snapshot() { call_id: "call-approve-exec".into(), approval_id: Some("call-approve-exec".into()), turn_id: "turn-approve-exec".into(), + environment_id: None, command: vec!["echo".into(), "hello world".into()], cwd: test_path_buf("/tmp").abs(), reason: Some( @@ -2041,6 +2148,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; @@ -3613,7 +3906,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { AppServerThreadItem::CommandExecution { id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::InProgress, @@ -3628,7 +3921,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { AppServerThreadItem::CommandExecution { id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd, + cwd: cwd.into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::Completed, 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 c206313549af..21e02db25265 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -1,5 +1,6 @@ use super::*; use assert_matches::assert_matches; +use codex_utils_path_uri::PathUri; #[tokio::test] async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() { @@ -45,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); @@ -96,9 +96,23 @@ async fn status_command_uses_catalog_default_reasoning_when_config_empty() { } #[tokio::test] -async fn status_command_renders_instruction_sources_from_thread_session() { +async fn status_command_renders_native_and_foreign_instruction_sources() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.instruction_source_paths = vec![chat.config.cwd.join("AGENTS.md")]; + let (foreign_source, foreign_display) = if cfg!(windows) { + ( + PathUri::parse("file:///remote/AGENTS.md").expect("POSIX instruction source"), + "/remote/AGENTS.md", + ) + } else { + ( + PathUri::parse("file:///C:/remote/AGENTS.md").expect("Windows instruction source"), + r"C:\remote\AGENTS.md", + ) + }; + chat.instruction_source_paths = vec![ + PathUri::from_abs_path(&chat.config.cwd.join("AGENTS.md")), + foreign_source, + ]; chat.dispatch_command(SlashCommand::Status); @@ -109,8 +123,8 @@ async fn status_command_renders_instruction_sources_from_thread_session() { other => panic!("expected status output, got {other:?}"), }; assert!( - rendered.contains("Agents.md"), - "expected /status to render app-server instruction sources, got: {rendered}" + rendered.contains(&format!("AGENTS.md, {foreign_display}")), + "expected /status to show native-relative and environment-native foreign paths, got: {rendered}" ); assert!( !rendered.contains("Agents.md "), @@ -155,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 2528d75fa66f..6a5cd48f228f 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -182,6 +182,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/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index ee7677f87b89..9b6a75f2cf3c 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -14,6 +14,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { call_id: "call-action-required".into(), approval_id: Some("call-action-required".into()), turn_id: "turn-action-required".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), @@ -57,6 +58,7 @@ async fn terminal_title_action_required_respects_spinner_setting() { call_id: "call-no-spinner".into(), approval_id: Some("call-no-spinner".into()), turn_id: "turn-no-spinner".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), @@ -86,6 +88,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { call_id: "call-blink".into(), approval_id: Some("call-blink".into()), turn_id: "turn-blink".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), @@ -122,6 +125,7 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d call_id: "call-no-animations".into(), approval_id: Some("call-no-animations".into()), turn_id: "turn-no-animations".into(), + environment_id: None, command: vec!["bash".into(), "-lc".into(), "echo hello".into()], cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: Some("need confirmation".into()), diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs new file mode 100644 index 000000000000..c05b4a62ffac --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -0,0 +1,778 @@ +use super::*; +use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome; +use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; +use codex_app_server_protocol::RateLimitResetCreditsSummary; +use uuid::Uuid; + +const TEST_OVERLAY_VIEW_ID: &str = "usage-test-overlay"; + +#[tokio::test] +async fn usage_command_opens_menu_when_reset_is_available_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: 2 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + + assert_chatwidget_snapshot!( + "usage_command_menu", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +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 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + + assert_chatwidget_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; + set_chatgpt_auth(&mut chat); + chat.start_rate_limit_reset_startup_check(); + + chat.dispatch_command(SlashCommand::Usage); + + assert_chatwidget_snapshot!( + "usage_command_menu_before_reset_refresh", + render_bottom_popup(&chat, /*width*/ 80) + ); + 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_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); + + chat.dispatch_command(SlashCommand::Usage); + + 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_rate_limit_reset_entry_opens_reset_flow() { + 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: 2 }), + )); + chat.dispatch_command(SlashCommand::Usage); + + 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 rate_limit_reset_popup_states_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let mut states = Vec::new(); + + let loading_request_id = chat.show_rate_limit_reset_loading_popup(); + 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); + + dismiss_popup(&mut chat); + 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); + + dismiss_popup(&mut chat); + 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); + + dismiss_popup(&mut chat); + let consuming_request_id = chat.show_rate_limit_reset_consuming_popup(); + record_popup(&chat, &mut states); + assert!(!chat.finish_rate_limit_reset_consume( + consuming_request_id, + "redeem-1".to_string(), + Err("request timed out".to_string()), + )); + record_popup(&chat, &mut states); + + dismiss_popup(&mut chat); + let nothing_request_id = chat.show_rate_limit_reset_consuming_popup(); + assert!(!finish_reset_consume_outcome( + &mut chat, + nothing_request_id, + "redeem-2", + ConsumeAccountRateLimitResetCreditOutcome::NothingToReset, + )); + record_popup(&chat, &mut states); + + dismiss_popup(&mut chat); + let no_credit_request_id = chat.show_rate_limit_reset_consuming_popup(); + assert!(!finish_reset_consume_outcome( + &mut chat, + no_credit_request_id, + "redeem-3", + ConsumeAccountRateLimitResetCreditOutcome::NoCredit, + )); + record_popup(&chat, &mut states); + + dismiss_popup(&mut chat); + let success_request_id = chat.show_rate_limit_reset_consuming_popup(); + assert!(finish_reset_consume_outcome( + &mut chat, + success_request_id, + "redeem-4", + ConsumeAccountRateLimitResetCreditOutcome::Reset, + )); + 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); + + 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 }), + )); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.bottom_pane.no_modal_or_popup_active()); + assert!(rx.try_recv().is_err()); +} + +#[tokio::test] +async fn rate_limit_reset_confirmation_can_use_reset() { + 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 }), + )); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key }) + if Uuid::parse_str(&idempotency_key).is_ok() + ); +} + +#[tokio::test] +async fn rate_limit_reset_retry_reuses_idempotency_key() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let request_id = chat.show_rate_limit_reset_consuming_popup(); + assert!(!chat.finish_rate_limit_reset_consume( + request_id, + "stable-redeem-id".to_string(), + Err("response lost".to_string()), + )); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key }) + if idempotency_key == "stable-redeem-id" + ); +} + +#[tokio::test] +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(); + assert!(!finish_reset_consume_outcome( + &mut chat, + consume_request_id, + "redeem-1", + ConsumeAccountRateLimitResetCreditOutcome::NoCredit, + )); + 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::OpenTokenActivity)); +} + +#[tokio::test] +async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let request_id = chat.show_rate_limit_reset_consuming_popup(); + dismiss_popup(&mut chat); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Using a reset...")); + + assert!(finish_reset_consume_outcome( + &mut chat, + request_id, + "redeem-123", + ConsumeAccountRateLimitResetCreditOutcome::Reset, + )); + dismiss_popup(&mut chat); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Refreshing...")); + + assert!(chat.finish_post_consume_reset_credits_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + dismiss_popup(&mut chat); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + +#[tokio::test] +async fn rate_limit_reset_redemption_allows_ctrl_c_to_quit_while_in_flight() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.show_rate_limit_reset_consuming_popup(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Using a reset...")); +} + +#[tokio::test] +async fn already_redeemed_is_an_idempotent_success() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let request_id = chat.show_rate_limit_reset_consuming_popup(); + + assert!(finish_reset_consume_outcome( + &mut chat, + request_id, + "stable-redeem-id", + ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed, + )); + 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 usage limit resets left.") + ); +} + +#[tokio::test] +async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { + let (mut chat, _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: 2 }), + )); + let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); + assert!(finish_reset_consume_outcome( + &mut chat, + consume_request_id, + "redeem-with-refresh-error", + ConsumeAccountRateLimitResetCreditOutcome::Reset, + )); + + assert!(chat.finish_post_consume_reset_credits_refresh( + consume_request_id, + Vec::new(), + Err("backend unavailable".to_string()), + )); + dismiss_popup(&mut chat); + chat.dispatch_command(SlashCommand::Usage); + + let rendered = render_bottom_popup(&chat, /*width*/ 80); + assert!(rendered.contains("Check reset availability.")); + assert!(!rendered.contains("You have 2 usage limit resets available.")); +} + +#[tokio::test] +async fn account_change_invalidates_pending_reset_requests() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.show_rate_limit_reset_loading_popup(); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + + 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()); +} + +#[tokio::test] +async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); + 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 }), + )); + + chat.clear_pending_rate_limit_reset_hint(); + + assert!(chat.pending_rate_limit_reset_hint().is_none()); + assert!(finish_reset_consume_outcome( + &mut chat, + consume_request_id, + "redeem-after-rollback", + ConsumeAccountRateLimitResetCreditOutcome::Reset, + )); +} + +#[tokio::test] +async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let request_id = chat.show_rate_limit_reset_loading_popup(); + show_usage_test_overlay(&mut chat); + + assert!(chat.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 2 }), + )); + assert_eq!( + chat.bottom_pane.active_view_id(), + Some(TEST_OVERLAY_VIEW_ID) + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + render_bottom_popup(&chat, /*width*/ 80) + .contains("You have 2 usage limit resets available.") + ); +} + +#[tokio::test] +async fn rate_limit_reset_success_updates_popup_beneath_overlay() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let request_id = chat.show_rate_limit_reset_consuming_popup(); + show_usage_test_overlay(&mut chat); + + assert!(finish_reset_consume_outcome( + &mut chat, + request_id, + "redeem-covered", + ConsumeAccountRateLimitResetCreditOutcome::Reset, + )); + assert!(chat.finish_post_consume_reset_credits_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + assert_eq!( + chat.bottom_pane.active_view_id(), + Some(TEST_OVERLAY_VIEW_ID) + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + render_bottom_popup(&chat, /*width*/ 80) + .contains("Usage reset. You have 1 usage limit reset left.") + ); +} + +#[tokio::test] +async fn account_change_dismisses_reset_popup_beneath_overlay() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.show_rate_limit_reset_loading_popup(); + show_usage_test_overlay(&mut chat); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + assert_eq!( + chat.bottom_pane.active_view_id(), + Some(TEST_OVERLAY_VIEW_ID) + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + +#[tokio::test] +async fn startup_check_shows_available_reset_hint_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + 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 }), + )); + let rendered = lines_to_single_string( + &chat + .pending_rate_limit_reset_hint() + .expect("pending reset hint") + .display_lines(/*width*/ 80), + ); + assert_chatwidget_snapshot!("rate_limit_reset_available_hint", rendered); +} + +#[tokio::test] +async fn startup_reset_hint_waits_for_active_output_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let hint_request_id = chat.start_rate_limit_reset_startup_check(); + chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(vec![Line::from( + "active tool", + )]))); + + assert!(chat.finish_rate_limit_reset_hint_refresh( + hint_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 2 }), + )); + + assert!(chat.usage_history_insertion_blocked()); + assert!(drain_insert_history(&mut rx).is_empty()); + assert_chatwidget_snapshot!( + "rate_limit_reset_hint_waits_for_active_output", + lines_to_single_string( + &chat + .active_cell_transcript_lines(/*width*/ 80) + .expect("active output with reset hint"), + ) + ); + + chat.flush_active_cell(); + + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput)); +} + +#[tokio::test] +async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let hint_request_id = chat.start_rate_limit_reset_startup_check(); + + chat.show_rate_limit_reset_loading_popup(); + + 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()); +} + +#[tokio::test] +async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + 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()); + + chat.show_rate_limit_reset_consuming_popup(); + + assert!(chat.pending_rate_limit_reset_hint().is_none()); +} + +#[tokio::test] +async fn startup_check_omits_reset_hint_when_none_are_available() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + 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: 0 }), + )); + assert!(chat.pending_rate_limit_reset_hint().is_none()); +} + +#[tokio::test] +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); + 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()); + assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); +} + +fn consume_response( + outcome: ConsumeAccountRateLimitResetCreditOutcome, +) -> ConsumeAccountRateLimitResetCreditResponse { + ConsumeAccountRateLimitResetCreditResponse { outcome } +} + +fn finish_reset_consume_outcome( + chat: &mut ChatWidget, + request_id: u64, + idempotency_key: &str, + outcome: ConsumeAccountRateLimitResetCreditOutcome, +) -> bool { + chat.finish_rate_limit_reset_consume( + request_id, + idempotency_key.to_string(), + Ok(consume_response(outcome)), + ) +} + +fn record_popup(chat: &ChatWidget, states: &mut Vec) { + states.push(render_bottom_popup(chat, /*width*/ 80)); +} + +fn dismiss_popup(chat: &mut ChatWidget) { + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); +} + +fn show_usage_test_overlay(chat: &mut ChatWidget) { + chat.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(TEST_OVERLAY_VIEW_ID), + title: Some("Covering overlay".to_string()), + items: vec![SelectionItem { + name: "Close".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + }); +} diff --git a/codex-rs/tui/src/chatwidget/tokens.rs b/codex-rs/tui/src/chatwidget/tokens.rs index 85475899b4a4..52db0a60f623 100644 --- a/codex-rs/tui/src/chatwidget/tokens.rs +++ b/codex-rs/tui/src/chatwidget/tokens.rs @@ -31,7 +31,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::plain_lines; -pub(super) use chart::TokenActivityView; +pub(crate) use chart::TokenActivityView; /// Tracks the renderable lifecycle of one token activity history cell. #[derive(Debug)] @@ -153,7 +153,7 @@ impl ChatWidget { /// Each invocation receives a request ID so background responses update only /// their own card. The card remains outside transcript history until completion, /// which keeps loading visible without disturbing existing transcript content. - pub(super) fn add_token_activity_output(&mut self, view: TokenActivityView) { + pub(crate) fn add_token_activity_output(&mut self, view: TokenActivityView) { let request_id = self.next_token_activity_request_id; self.next_token_activity_request_id = self.next_token_activity_request_id.wrapping_add(/*rhs*/ 1); @@ -210,12 +210,12 @@ impl ChatWidget { true } - /// Reports whether a completed token activity card must wait before insertion. + /// Reports whether completed asynchronous usage output must wait before insertion. /// /// Inserting while a stream, queued consolidation, or active transcript cell is - /// present can reorder the card relative to visible output, so callers retry once + /// present can reorder output relative to visible work, so callers retry once /// these barriers clear. - pub(crate) fn token_activity_history_insertion_blocked(&self) -> bool { + pub(crate) fn usage_history_insertion_blocked(&self) -> bool { self.stream_controller.is_some() || self.plan_stream_controller.is_some() || self.pending_stream_consolidations > 0 @@ -244,7 +244,7 @@ impl ChatWidget { /// Transfers the completed token activity card into the history insertion path. /// /// Callers should use this only after - /// [`ChatWidget::token_activity_history_insertion_blocked`] returns `false`; + /// [`ChatWidget::usage_history_insertion_blocked`] returns `false`; /// taking the card removes it from the transient render area. pub(crate) fn take_completed_token_activity_output(&mut self) -> Option { let output = self.completed_token_activity_output.take()?; @@ -252,14 +252,24 @@ impl ChatWidget { Some(output) } - /// Requests another insertion attempt when a completed card is waiting. + /// Requests another insertion attempt when completed usage output is waiting. /// /// This is used after stream or history lifecycle events that may have cleared - /// the insertion barriers without directly owning the completed card. - pub(crate) fn request_completed_token_activity_output_insertion(&self) { - if self.completed_token_activity_output.is_some() { + /// the insertion barriers without directly owning the completed output. + pub(crate) fn request_pending_usage_output_insertion(&self) { + if self.completed_token_activity_output.is_some() + || self.pending_rate_limit_reset_hint().is_some() + { + self.app_event_tx.send(AppEvent::CommitPendingUsageOutput); + } + } + + pub(crate) fn request_pending_usage_output_insertion_after_stream_shutdown(&self) { + if self.completed_token_activity_output.is_some() + || self.pending_rate_limit_reset_hint().is_some() + { self.app_event_tx - .send(AppEvent::CommitCompletedTokenActivityOutput); + .send(AppEvent::CommitPendingUsageOutputAfterStreamShutdown); } } diff --git a/codex-rs/tui/src/chatwidget/tokens/chart.rs b/codex-rs/tui/src/chatwidget/tokens/chart.rs index 58249c6b77b4..9a9fc27e2629 100644 --- a/codex-rs/tui/src/chatwidget/tokens/chart.rs +++ b/codex-rs/tui/src/chatwidget/tokens/chart.rs @@ -30,7 +30,7 @@ const SUMMARY_INDENT_WIDTH: u16 = 1; /// Selects the aggregation represented by the token activity chart. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::chatwidget) enum TokenActivityView { +pub(crate) enum TokenActivityView { Daily, Weekly, Cumulative, diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 3001563451aa..f3ff2391f64c 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -296,6 +296,7 @@ impl ChatWidget { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, id: ev.effective_approval_id(), + environment_id: ev.environment_id, command: ev.command, reason: ev.reason, available_decisions, @@ -375,7 +376,8 @@ impl ChatWidget { self.bottom_pane .push_approval_request(request, &self.config.features); } - McpServerElicitationRequest::Url { .. } => { + McpServerElicitationRequest::OpenAiForm { .. } + | McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name, diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index e8590df7a221..874e55b86e31 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -59,7 +59,7 @@ impl ChatWidget { self.transcript.reset_turn_flags(); self.adaptive_chunking.reset(); if self.plan_stream_controller.take().is_some() { - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion_after_stream_shutdown(); } self.turn_runtime_metrics = RuntimeMetricsSummary::default(); self.session_telemetry.reset_runtime_metrics(); @@ -135,7 +135,7 @@ impl ChatWidget { self.app_event_tx .send(AppEvent::ConsolidateProposedPlan(source)); } - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion_after_stream_shutdown(); } self.flush_unified_exec_wait_streak(); if !from_replay { @@ -342,7 +342,7 @@ impl ChatWidget { self.adaptive_chunking.reset(); self.stream_controller = None; self.plan_stream_controller = None; - self.request_completed_token_activity_output_insertion(); + self.request_pending_usage_output_insertion_after_stream_shutdown(); self.status_state.pending_status_indicator_restore = false; self.clear_cancel_edit(); self.request_status_line_branch_refresh(); @@ -391,8 +391,9 @@ impl ChatWidget { } pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { + let usage_limit_error = matches!(error_kind, RateLimitErrorKind::UsageLimit); let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { - if matches!(error_kind, RateLimitErrorKind::UsageLimit) { + if usage_limit_error { match kind { RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { RateLimitReachedType::WorkspaceOwnerUsageLimitReached @@ -407,7 +408,6 @@ impl ChatWidget { } }); self.codex_rate_limit_reached_type = rate_limit_reached_type; - match rate_limit_reached_type { Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { self.on_error( diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs new file mode 100644 index 000000000000..53925e86d90a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -0,0 +1,455 @@ +use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome; +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"; +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 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 => ( + true, + format!( + "You have {available_count} {} available.", + reset_label(available_count) + ), + ), + (true, None) => (true, "Check reset availability.".to_string()), + (true, Some(_)) | (false, _) => { + (false, "No usage limit resets available.".to_string()) + } + }; + 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()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Show usage".to_string(), + description: Some("View recent account token usage.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenTokenActivity); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Redeem usage limit reset".to_string(), + description: Some(reset_description), + is_disabled: !reset_action_enabled, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenRateLimitResetCredits); + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + } + } + + 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 { + self.clear_pending_rate_limit_reset_hint(); + let request_id = self.take_next_rate_limit_reset_request_id(); + 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("Usage limit resets".to_string()), + subtitle: Some("Checking your available resets...".to_string()), + items: vec![SelectionItem { + name: "Loading...".to_string(), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + }); + self.request_redraw(); + request_id + } + + 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) + } else { + Self::rate_limit_reset_message_params( + "You don't have any usage limit resets available.", + ) + } + } + Err(_) => Self::rate_limit_reset_message_params( + "Couldn't load usage limit resets. Please try again.", + ), + }; + let replaced = self + .bottom_pane + .replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params); + if replaced { + self.request_redraw(); + } + replaced + } + + 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("Usage limit resets".to_string()), + subtitle: Some(format!( + "You have {available_count} {} available.", + reset_label(available_count) + )), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Use a reset".to_string(), + description: Some(reset_description.to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key: idempotency_key.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + initial_selected_idx: Some(1), + ..Default::default() + } + } + + fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(RATE_LIMIT_RESET_VIEW_ID), + title: Some("Usage limit resets".to_string()), + subtitle: Some(message.to_string()), + items: vec![SelectionItem { + name: "Close".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(crate) fn show_rate_limit_reset_consuming_popup(&mut self) -> u64 { + self.clear_pending_rate_limit_reset_hint(); + let request_id = self.take_next_rate_limit_reset_request_id(); + 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("Usage limit resets".to_string()), + subtitle: Some("Resetting your usage...".to_string()), + items: vec![SelectionItem { + name: "Using a reset...".to_string(), + is_disabled: true, + ..Default::default() + }], + allow_cancel: false, + ..Default::default() + }); + self.request_redraw(); + request_id + } + + pub(crate) fn finish_rate_limit_reset_consume( + &mut self, + request_id: u64, + idempotency_key: String, + result: Result, + ) -> bool { + if self.pending_rate_limit_reset_request_id != Some(request_id) { + return false; + } + + match result { + Ok(response) + if matches!( + response.outcome, + ConsumeAccountRateLimitResetCreditOutcome::Reset + | ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed + ) => + { + self.available_rate_limit_reset_credits = None; + self.replace_rate_limit_reset_popup(Self::rate_limit_reset_success_loading_params()); + true + } + Ok(response) => { + self.pending_rate_limit_reset_request_id = None; + let message = match response.outcome { + ConsumeAccountRateLimitResetCreditOutcome::NothingToReset => { + "Your usage does not need a reset right now." + } + ConsumeAccountRateLimitResetCreditOutcome::NoCredit => { + self.available_rate_limit_reset_credits = Some(0); + "No usage limit resets are available." + } + ConsumeAccountRateLimitResetCreditOutcome::Reset + | ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(), + }; + self.replace_rate_limit_reset_popup(Self::rate_limit_reset_message_params(message)); + false + } + Err(_) => { + 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("Usage limit resets".to_string()), + subtitle: Some("Couldn't reset usage. Please try again.".to_string()), + items: vec![ + SelectionItem { + name: "Try again".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key: idempotency_key.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Close".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + false + } + } + } + + 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) => { + self.available_rate_limit_reset_credits = Some(response.available_count); + format!( + "Usage reset. You have {} {} left.", + response.available_count, + reset_label(response.available_count) + ) + } + Err(_) => "Usage reset.".to_string(), + }; + self.replace_rate_limit_reset_popup(Self::rate_limit_reset_message_params(&message)); + true + } + + fn rate_limit_reset_success_loading_params() -> SelectionViewParams { + SelectionViewParams { + view_id: Some(RATE_LIMIT_RESET_VIEW_ID), + title: Some("Usage limit resets".to_string()), + subtitle: Some("Usage reset. Checking your remaining resets...".to_string()), + items: vec![SelectionItem { + name: "Refreshing...".to_string(), + is_disabled: true, + ..Default::default() + }], + allow_cancel: false, + ..Default::default() + } + } + + fn replace_rate_limit_reset_popup(&mut self, params: SelectionViewParams) { + if self + .bottom_pane + .replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params) + { + self.request_redraw(); + } + } + + pub(crate) fn start_rate_limit_reset_startup_check(&mut self) -> u64 { + self.clear_pending_rate_limit_reset_hint(); + let request_id = self.take_next_rate_limit_reset_request_id(); + self.pending_rate_limit_reset_hint_request_id = Some(request_id); + request_id + } + + 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 let Ok(response) = result { + self.available_rate_limit_reset_credits = Some(response.available_count); + self.set_rate_limit_reset_available_hint(response.available_count); + } + true + } + + 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); + } + + pub(crate) fn clear_pending_rate_limit_reset_hint(&mut self) { + self.pending_rate_limit_reset_hint_request_id = None; + let cleared_hint = self.pending_rate_limit_reset_hint.take().is_some(); + if cleared_hint { + self.bump_active_cell_revision(); + self.request_redraw(); + } + } + + pub(super) fn pending_rate_limit_reset_hint(&self) -> Option<&PlainHistoryCell> { + self.pending_rate_limit_reset_hint.as_ref() + } + + pub(crate) fn take_pending_rate_limit_reset_hint(&mut self) -> Option { + let hint = self.pending_rate_limit_reset_hint.take()?; + self.bump_active_cell_revision(); + Some(hint) + } + + fn set_rate_limit_reset_available_hint(&mut self, available_count: i64) { + if available_count <= 0 { + return; + } + self.pending_rate_limit_reset_hint = Some(history_cell::new_info_event( + format!( + "You have {available_count} {} available. Run /usage to use one.", + reset_label(available_count) + ), + /*hint*/ None, + )); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + fn take_next_rate_limit_reset_request_id(&mut self) -> u64 { + let request_id = self.next_rate_limit_reset_request_id; + self.next_rate_limit_reset_request_id = self + .next_rate_limit_reset_request_id + .wrapping_add(/*rhs*/ 1); + request_id + } +} + +fn reset_label(count: i64) -> &'static str { + if count == 1 { + "usage limit reset" + } else { + "usage limit resets" + } +} diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 350c3c7aa059..9df58fb7e66f 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -1307,6 +1307,7 @@ fn style_gutter_dim() -> Style { #[cfg(test)] mod tests { use super::*; + use insta::assert_debug_snapshot; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::Terminal; @@ -2200,6 +2201,58 @@ mod tests { ); } + #[test] + fn cpp_module_extensions_use_cpp_highlighting() { + let highlighted_tokens = ["cpp", "cppm", "CPPM", "cxxm", "CxXm", "ixx", "IXX"] + .into_iter() + .map(|extension| { + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from(format!("math.{extension}")), + FileChange::Add { + content: + "export module math;\nexport int sum(int a, int b) { return a + b; }\n" + .to_string(), + }, + ); + + let lines = + create_diff_summary(&changes, &PathBuf::from("/"), /*wrap_cols*/ 80); + let rgb_tokens = lines + .iter() + .flat_map(|line| &line.spans) + .filter(|span| matches!(span.style.fg, Some(ratatui::style::Color::Rgb(..)))) + .map(|span| span.content.to_string()) + .collect::>(); + assert!( + !rgb_tokens.is_empty(), + "add diff for .{extension} file should produce syntax-highlighted (RGB) spans" + ); + (extension, rgb_tokens.join("|")) + }) + .collect::>(); + + assert_debug_snapshot!("cpp_module_extension_highlighting", highlighted_tokens); + } + + #[test] + fn unknown_extension_falls_back_without_syntax_highlighting() { + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("math.unknown-extension"), + FileChange::Add { + content: "export module math;\nexport int value = 42;\n".to_string(), + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), /*wrap_cols*/ 80); + assert!(lines.iter().all(|line| { + line.spans + .iter() + .all(|span| !matches!(span.style.fg, Some(ratatui::style::Color::Rgb(..)))) + })); + } + #[test] fn delete_diff_uses_path_extension_for_highlighting() { let mut changes: HashMap = HashMap::new(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 127698807bf1..b0f96df9339d 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; @@ -202,6 +201,7 @@ mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; +mod workspace_messages; mod wrapping; @@ -401,6 +401,7 @@ async fn connect_remote_app_server( client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -564,6 +565,7 @@ where client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -958,6 +960,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 +976,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 +1166,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 +1175,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 +1244,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 +1443,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; } @@ -1878,7 +1892,7 @@ async fn get_login_status( Some(AppServerAccount::Chatgpt { .. } | AppServerAccount::ChatgptPool { .. }) => { LoginStatus::AuthMode(AppServerAuthMode::Chatgpt) } - Some(AppServerAccount::AmazonBedrock {}) => LoginStatus::NotAuthenticated, + Some(AppServerAccount::AmazonBedrock { .. }) => LoginStatus::NotAuthenticated, None => LoginStatus::NotAuthenticated, }) } @@ -2018,7 +2032,7 @@ mod tests { F: FnOnce() -> Fut + Send + 'static, Fut: std::future::Future> + 'static, { - const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; let handle = std::thread::Builder::new() .name(name.to_string()) @@ -2069,6 +2083,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 fceb7fb059d8..8ba409f7c9ed 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(), @@ -1055,6 +1056,7 @@ mod tests { client_name: "test".to_string(), client_version: "test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index cb83f06f4efa..ef4d7a2f353a 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -526,8 +526,10 @@ fn find_syntax(lang: &str) -> Option<&'static SyntaxReference> { let ss = syntax_set(); // Aliases that two-face does not resolve on its own. - let patched = match lang { + let normalized = lang.to_ascii_lowercase(); + let patched = match normalized.as_str() { "csharp" | "c-sharp" => "c#", + "cppm" | "cxxm" | "ixx" => "cpp", "golang" => "go", "python3" => "python", "shell" => "bash", @@ -1203,7 +1205,10 @@ mod tests { ); } // Patched aliases that two-face cannot resolve on its own. - for alias in ["csharp", "c-sharp", "golang", "python3", "shell"] { + for alias in [ + "csharp", "c-sharp", "cppm", "CPPM", "cxxm", "CxXm", "ixx", "IXX", "golang", "python3", + "shell", + ] { assert!( find_syntax(alias).is_some(), "find_syntax({alias:?}) returned None — patched alias broken" diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 18313bcca6b0..08191eaf2bfe 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -608,7 +608,7 @@ fn spawn_app_server_page_loader( fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { match sort_key { ThreadSortKey::CreatedAt => "Created", - ThreadSortKey::UpdatedAt => "Updated", + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => "Updated", } } @@ -1614,7 +1614,7 @@ impl PickerState { fn toggle_sort_key(&mut self) { self.sort_key = match self.sort_key { ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt, - ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => ThreadSortKey::CreatedAt, }; self.start_initial_load(); } @@ -2613,7 +2613,7 @@ fn render_dense_session_lines( let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); let date = match state.sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut lines = vec![dense_summary_line(DenseSummaryInput { marker, @@ -2742,7 +2742,7 @@ fn render_footer_lines( ) -> Vec> { let date = match sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut parts = vec![FooterPart::Date(date.to_string())]; if show_cwd { @@ -5728,6 +5728,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5763,6 +5764,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5832,6 +5834,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5890,6 +5893,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 852aaad5ce58..56bcfae6ec4f 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/session_state.rs b/codex-rs/tui/src/session_state.rs index 7988ba2264ef..059e148a7908 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -12,6 +12,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; #[derive(Debug, Clone, PartialEq)] pub(crate) struct SessionNetworkProxyRuntime { @@ -47,7 +48,7 @@ pub(crate) struct ThreadSessionState { pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) runtime_workspace_roots: Vec, - pub(crate) instruction_source_paths: Vec, + pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, pub(crate) collaboration_mode: Option>, pub(crate) personality: Option, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 78e1eb9c7081..0bf7878c809b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -105,7 +105,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 => "show account usage activity", + 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", @@ -196,13 +196,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 @@ -219,6 +215,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/snapshots/codex_tui__diff_render__tests__cpp_module_extension_highlighting.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__cpp_module_extension_highlighting.snap new file mode 100644 index 000000000000..be5ab4fe179d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__cpp_module_extension_highlighting.snap @@ -0,0 +1,34 @@ +--- +source: tui/src/diff_render.rs +expression: highlighted_tokens +--- +[ + ( + "cpp", + "export| module math|;|export| |int| |sum|(|int| |a|,| |int| |b|)| |{| |return| a |+| b|;| |}", + ), + ( + "cppm", + "export| module math|;|export| |int| |sum|(|int| |a|,| |int| |b|)| |{| |return| a |+| b|;| |}", + ), + ( + "CPPM", + "export| module math|;|export| |int| |sum|(|int| |a|,| |int| |b|)| |{| |return| a |+| b|;| |}", + ), + ( + "cxxm", + "export| module math|;|export| |int| |sum|(|int| |a|,| |int| |b|)| |{| |return| a |+| b|;| |}", + ), + ( + "CxXm", + "export| module math|;|export| |int| |sum|(|int| |a|,| |int| |b|)| |{| |return| a |+| b|;| |}", + ), + ( + "ixx", + "export| module math|;|export| |int| |sum|(|int| |a|,| |int| |b|)| |{| |return| a |+| b|;| |}", + ), + ( + "IXX", + "export| module math|;|export| |int| |sum|(|int| |a|,| |int| |b|)| |{| |return| a |+| b|;| |}", + ), +] diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index f3ceeb435f18..f03336a4b0e7 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -5,7 +5,8 @@ use crate::text_formatting; use chrono::DateTime; use chrono::Local; use codex_protocol::account::PlanType; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::path::Path; use unicode_width::UnicodeWidthStr; @@ -33,10 +34,20 @@ pub(crate) fn compose_model_display( (model_name.to_string(), details) } -pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf]) -> String { +pub(crate) fn compose_agents_summary(config: &Config, paths: &[PathUri]) -> String { let mut rels: Vec = Vec::new(); - for p in paths { + for path in paths { + // TODO(anp): Rationalize instruction-source summaries with the TUI's broader foreign-path + // display strategy once other status surfaces can retain environment-native paths. + if path.infer_path_convention() != Some(PathConvention::native()) { + rels.push(path.inferred_native_path_string()); + continue; + } + let Ok(p) = path.to_abs_path() else { + rels.push(path.inferred_native_path_string()); + continue; + }; let p = p.as_path(); let file_name = p .file_name() @@ -229,7 +240,10 @@ mod tests { let config = test_config(&codex_home, &cwd).await; assert_eq!( - compose_agents_summary(&config, &[global_agents_path.abs()]), + compose_agents_summary( + &config, + &[PathUri::from_abs_path(&global_agents_path.abs())] + ), format_directory_display(&global_agents_path, /*max_width*/ None) ); } @@ -242,11 +256,33 @@ mod tests { let config = test_config(&codex_home, &cwd).await; assert_eq!( - compose_agents_summary(&config, &[override_path.abs()]), + compose_agents_summary(&config, &[PathUri::from_abs_path(&override_path.abs())]), format_directory_display(&override_path, /*max_width*/ None) ); } + #[tokio::test] + async fn compose_agents_summary_shows_relative_native_and_full_foreign_paths() { + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let config = test_config(&codex_home, &cwd).await; + let native_source = PathUri::from_abs_path(&config.cwd.join("AGENTS.md")); + let foreign_source = if cfg!(windows) { + PathUri::parse("file:///remote%20workspace/AGENTS.md") + .expect("POSIX instruction source") + } else { + PathUri::parse("file:///C:/remote%20workspace/AGENTS.md") + .expect("Windows instruction source") + }; + + let summary = compose_agents_summary(&config, &[native_source, foreign_source]); + if cfg!(windows) { + insta::assert_snapshot!(summary, @r"AGENTS.md, /remote workspace/AGENTS.md"); + } else { + insta::assert_snapshot!(summary, @r"AGENTS.md, C:\remote workspace\AGENTS.md"); + } + } + #[tokio::test] async fn compose_agents_summary_orders_global_before_project_agents() { let codex_home = TempDir::new().expect("temp codex home"); @@ -258,8 +294,8 @@ mod tests { let summary = compose_agents_summary( &config, &[ - global_agents_path.clone().abs(), - project_agents_path.clone().abs(), + PathUri::from_abs_path(&global_agents_path.clone().abs()), + PathUri::from_abs_path(&project_agents_path.clone().abs()), ], ); let mut paths = summary.split(", "); 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 000000000000..4ac5239b945e --- /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 bcdd9af73f23..393939d770bd 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; @@ -339,6 +343,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/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index bea195083b30..9e88dda932ab 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -1,16 +1,16 @@ -//! Short, best-effort terminal response probes for TUI startup. +//! Short, best-effort terminal response probes for TUI startup and resume. //! //! Crossterm's public helpers wait up to two seconds for terminal responses. That is too long for -//! TUI startup, where unsupported terminals should simply fall back to conservative defaults. +//! TUI startup and resume, where unsupported terminals should simply fall back to conservative +//! defaults. //! This module sends the same kinds of optional terminal queries with a caller-provided deadline, //! prefers duplicated stdio handles, falls back to the controlling terminal path when stdio is //! unavailable, and reports `None` when a response is unavailable. //! -//! The probes run before the crossterm event stream is created, so they do not share crossterm's -//! internal skipped-event queue. Bytes read while looking for probe responses are consumed from the -//! terminal; keeping the timeout short is part of the contract that makes this acceptable for -//! startup. A future input-preservation layer would need to replay unrelated bytes through the same -//! parser that normal TUI input uses. +//! Probes run only while the crossterm event stream is absent or paused, so they do not share +//! crossterm's internal skipped-event queue. Bytes read while looking for probe responses are +//! consumed from the terminal; callers must therefore own terminal input for the duration of the +//! short timeout and accept that unrelated buffered input will be discarded. use std::time::Duration; @@ -58,7 +58,7 @@ mod imp { Skip, } - /// Temporary terminal handle used while a startup probe owns terminal input. + /// Temporary terminal handle used while a probe owns terminal input. /// /// The preferred path is duplicated stdin/stdout, because terminal replies are delivered to the /// same input stream crossterm reads from. Some embedded or redirected environments expose a @@ -72,7 +72,7 @@ mod imp { } impl Tty { - /// Opens an isolated reader and writer for startup probes. + /// Opens an isolated reader and writer for terminal probes. /// /// The reader and writer must be separate file descriptions so switching the reader into /// nonblocking mode does not also make writes fail with `WouldBlock` under terminal @@ -232,6 +232,17 @@ mod imp { Ok(Some(colors)) } + /// Queries the terminal cursor position while normal input polling is paused. + /// + /// Resume can emit a focus report immediately before the cursor-position response. Reusing + /// the startup parser lets the probe find the response without leaking either sequence into + /// the composer. + pub(crate) fn cursor_position(timeout: Duration) -> io::Result> { + let mut tty = Tty::open()?; + tty.write_all(b"\x1B[6n")?; + read_until(&mut tty, timeout, parse_cursor_position) + } + /// Runs the optional terminal queries needed during TUI startup under one shared deadline. /// /// Keeping these queries batched avoids paying one timeout per unsupported capability before @@ -255,8 +266,8 @@ mod imp { /// Reads available terminal bytes until `parse` recognizes a probe response or time expires. /// /// The accumulated buffer may include unrelated terminal input. This helper intentionally does - /// not try to replay those bytes, so it must stay limited to short startup probes that run - /// before normal crossterm input polling begins. + /// not try to replay those bytes, so callers must use it only during short, exclusive probe + /// windows before normal crossterm input polling begins or while that polling is paused. fn read_until( tty: &mut Tty, timeout: Duration, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 681e0cb3977f..a0f7ade044a0 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -281,6 +281,18 @@ pub fn restore() -> Result<()> { restore_common(RawModeRestore::Disable, KeyboardRestore::PopStack) } +/// Force crossterm's cached raw-mode state back in sync with the terminal after `fg`. +/// +/// A shell may restore the job's saved termios after the process receives `SIGCONT`. When that +/// races with [`set_modes`], crossterm still believes raw mode is enabled even though the terminal +/// has returned to canonical, echoing mode. Clearing crossterm's saved state before enabling raw +/// mode again makes the kernel state authoritative once the shell has completed its handoff. +#[cfg(unix)] +pub(super) fn reapply_raw_mode_after_resume() -> Result<()> { + disable_raw_mode()?; + enable_raw_mode() +} + /// Restore the terminal after Codex is exiting. /// /// Uses a stronger keyboard reset than [`restore`] so the parent shell recovers even if a @@ -873,7 +885,7 @@ impl Tui { #[cfg(unix)] let mut prepared_resume = self .suspend_context - .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); + .prepare_resume_action(&mut self.alt_saved_viewport); // Precompute any viewport updates that need a cursor-position query before entering // the synchronized update, to avoid racing with the event reader. @@ -1009,7 +1021,7 @@ impl Tui { #[cfg(unix)] let mut prepared_resume = self .suspend_context - .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); + .prepare_resume_action(&mut self.alt_saved_viewport); ensure_virtual_terminal_processing()?; diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index dcc6e17e0e73..9a5e416fd628 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -239,7 +239,16 @@ impl TuiEventStream { Event::Key(key_event) => { #[cfg(unix)] if crate::tui::job_control::SUSPEND_KEY.is_press(key_event) { - let _ = self.suspend_context.suspend(&self.alt_screen_active); + self.broker.pause_events(); + let suspend_result = self.suspend_context.suspend(&self.alt_screen_active); + self.broker.resume_events(); + if let Err(err) = suspend_result { + tracing::warn!( + event = "tui_suspend_failed", + error = %err, + "failed to suspend TUI process" + ); + } return Some(TuiEvent::Draw); } Some(TuiEvent::Key(key_event)) diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index a07d42840e13..bc46a0a8958b 100644 --- a/codex-rs/tui/src/tui/job_control.rs +++ b/codex-rs/tui/src/tui/job_control.rs @@ -13,7 +13,6 @@ use crossterm::event::KeyCode; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use ratatui::crossterm::execute; -use ratatui::layout::Position; use ratatui::layout::Rect; use crate::key_hint; @@ -72,7 +71,30 @@ impl SuspendContext { } let y = self.suspend_cursor_y.load(Ordering::Relaxed); let _ = execute!(stdout(), MoveTo(0, y), Show); - suspend_process() + suspend_process()?; + super::reapply_raw_mode_after_resume()?; + + // The shell writes its job-control status and the resumed command after `fg`, so the + // cursor may no longer be on the row cached before suspending. The event stream remains + // paused until this method returns, which makes it safe for the probe to consume both an + // interleaved focus report and the cursor-position response without racing the background + // input reader. + match crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT) { + Ok(Some(position)) => self.set_cursor_y(position.y), + Ok(None) => tracing::debug!("terminal cursor position unavailable after resume"), + Err(err) => tracing::debug!( + error = %err, + "failed to read terminal cursor position after resume" + ), + } + super::flush_terminal_input_buffer(); + tracing::trace!( + event = "tui_suspend_resumed", + cursor_y = self.cursor_y(), + "restored terminal state after resume" + ); + + Ok(()) } /// Consume the pending resume intent and precompute any viewport changes needed post-resume. @@ -81,23 +103,22 @@ impl SuspendContext { /// resumes; returns `None` when there was no pending suspend intent. pub(crate) fn prepare_resume_action( &self, - terminal: &mut Terminal, alt_saved_viewport: &mut Option, ) -> Option { let action = self.take_resume_action()?; match action { ResumeAction::RealignInline => { - let cursor_pos = terminal - .get_cursor_position() - .unwrap_or(terminal.last_known_cursor_pos); - let viewport = Rect::new(0, cursor_pos.y, 0, 0); + let viewport = Rect::new( + /*x*/ 0, + self.cursor_y(), + /*width*/ 0, + /*height*/ 0, + ); Some(PreparedResumeAction::RealignViewport(viewport)) } ResumeAction::RestoreAlt => { - if let Ok(Position { y, .. }) = terminal.get_cursor_position() - && let Some(saved) = alt_saved_viewport.as_mut() - { - saved.y = y; + if let Some(saved) = alt_saved_viewport.as_mut() { + saved.y = self.cursor_y(); } Some(PreparedResumeAction::RestoreAltScreen) } @@ -112,6 +133,10 @@ impl SuspendContext { self.suspend_cursor_y.store(value, Ordering::Relaxed); } + fn cursor_y(&self) -> u16 { + self.suspend_cursor_y.load(Ordering::Relaxed) + } + /// Record a pending resume action to apply after SIGTSTP returns control. fn set_resume_action(&self, value: ResumeAction) { *self diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 000000000000..4f5695f80c53 --- /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 000000000000..962d2ceba103 --- /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/tui/tests/suite/plugins__live.rs b/codex-rs/tui/tests/suite/plugins__live.rs index a5276f9e4bb4..a576fc419870 100644 --- a/codex-rs/tui/tests/suite/plugins__live.rs +++ b/codex-rs/tui/tests/suite/plugins__live.rs @@ -52,12 +52,7 @@ async fn plugin_popup_lists_installed_plugin_and_toggles_enabled_state() -> Resu }) .await?; - send_text(&writer, "/plugins").await?; - wait_for_screen(&mut output_rx, &mut screen, "plugins command", |contents| { - contents.contains("/plugins") - }) - .await?; - writer.send(b"\r".to_vec()).await?; + writer.send(b"/plugins\r".to_vec()).await?; wait_for_screen(&mut output_rx, &mut screen, "plugin list", |contents| { contents.contains("Plugins") && contents.contains("[ ] toggle-plugin") diff --git a/codex-rs/utils/output-truncation/src/lib.rs b/codex-rs/utils/output-truncation/src/lib.rs index 52cd741b7d09..4fcff7dce4f4 100644 --- a/codex-rs/utils/output-truncation/src/lib.rs +++ b/codex-rs/utils/output-truncation/src/lib.rs @@ -14,9 +14,12 @@ pub fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> Strin return content.to_string(); } + let original_token_count = approx_token_count(content); let total_lines = content.lines().count(); let result = truncate_text(content, policy); - format!("Total output lines: {total_lines}\n\n{result}") + format!( + "Warning: truncated output (original token count: {original_token_count})\nTotal output lines: {total_lines}\n\n{result}" + ) } pub fn truncate_text(content: &str, policy: TruncationPolicy) -> String { @@ -55,6 +58,7 @@ pub fn formatted_truncate_text_content_items_with_policy( return (items.to_vec(), None); } + let original_token_count = approx_token_count(&combined); let mut out = vec![FunctionCallOutputContentItem::InputText { text: formatted_truncate_text(&combined, policy), }]; @@ -73,7 +77,7 @@ pub fn formatted_truncate_text_content_items_with_policy( FunctionCallOutputContentItem::InputText { .. } => None, })); - (out, Some(approx_token_count(&combined))) + (out, Some(original_token_count)) } pub fn truncate_function_output_items_with_policy( diff --git a/codex-rs/utils/output-truncation/src/truncate_tests.rs b/codex-rs/utils/output-truncation/src/truncate_tests.rs index baf26a058ece..e8907cf8f859 100644 --- a/codex-rs/utils/output-truncation/src/truncate_tests.rs +++ b/codex-rs/utils/output-truncation/src/truncate_tests.rs @@ -14,7 +14,7 @@ fn truncate_bytes_less_than_placeholder_returns_placeholder() { let content = "example output"; assert_eq!( - "Total output lines: 1\n\n…13 chars truncated…t", + "Warning: truncated output (original token count: 4)\nTotal output lines: 1\n\n…13 chars truncated…t", formatted_truncate_text(content, TruncationPolicy::Bytes(1)), ); } @@ -24,7 +24,7 @@ fn truncate_tokens_less_than_placeholder_returns_placeholder() { let content = "example output"; assert_eq!( - "Total output lines: 1\n\nex…3 tokens truncated…ut", + "Warning: truncated output (original token count: 4)\nTotal output lines: 1\n\nex…3 tokens truncated…ut", formatted_truncate_text(content, TruncationPolicy::Tokens(1)), ); } @@ -54,7 +54,7 @@ fn truncate_tokens_over_limit_returns_truncated() { let content = "this is an example of a long output that should be truncated"; assert_eq!( - "Total output lines: 1\n\nthis is an…10 tokens truncated… truncated", + "Warning: truncated output (original token count: 15)\nTotal output lines: 1\n\nthis is an…10 tokens truncated… truncated", formatted_truncate_text(content, TruncationPolicy::Tokens(5)), ); } @@ -64,7 +64,7 @@ fn truncate_bytes_over_limit_returns_truncated() { let content = "this is an example of a long output that should be truncated"; assert_eq!( - "Total output lines: 1\n\nthis is an exam…30 chars truncated…ld be truncated", + "Warning: truncated output (original token count: 15)\nTotal output lines: 1\n\nthis is an exam…30 chars truncated…ld be truncated", formatted_truncate_text(content, TruncationPolicy::Bytes(30)), ); } @@ -75,7 +75,7 @@ fn truncate_bytes_reports_original_line_count_when_truncated() { "this is an example of a long output that should be truncated\nalso some other line"; assert_eq!( - "Total output lines: 2\n\nthis is an exam…51 chars truncated…some other line", + "Warning: truncated output (original token count: 21)\nTotal output lines: 2\n\nthis is an exam…51 chars truncated…some other line", formatted_truncate_text(content, TruncationPolicy::Bytes(30)), ); } @@ -86,7 +86,7 @@ fn truncate_tokens_reports_original_line_count_when_truncated() { "this is an example of a long output that should be truncated\nalso some other line"; assert_eq!( - "Total output lines: 2\n\nthis is an example o…11 tokens truncated…also some other line", + "Warning: truncated output (original token count: 21)\nTotal output lines: 2\n\nthis is an example o…11 tokens truncated…also some other line", formatted_truncate_text(content, TruncationPolicy::Tokens(10)), ); } @@ -201,7 +201,7 @@ fn formatted_truncate_text_content_items_with_policy_preserves_empty_leading_tex assert_eq!( output, vec![FunctionCallOutputContentItem::InputText { - text: "Total output lines: 1\n\n…3 chars truncated…".to_string(), + text: "Warning: truncated output (original token count: 1)\nTotal output lines: 1\n\n…3 chars truncated…".to_string(), }] ); assert_eq!(original_token_count, Some(1)); @@ -236,7 +236,7 @@ fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_ima output, vec![ FunctionCallOutputContentItem::InputText { - text: "Total output lines: 3\n\nabcd…6 chars truncated…ijkl".to_string(), + text: "Warning: truncated output (original token count: 4)\nTotal output lines: 3\n\nabcd…6 chars truncated…ijkl".to_string(), }, FunctionCallOutputContentItem::InputImage { image_url: "img:one".to_string(), @@ -269,7 +269,7 @@ fn formatted_truncate_text_content_items_with_policy_preserves_encrypted_content output, vec![ FunctionCallOutputContentItem::InputText { - text: "Total output lines: 1\n\na…6 chars truncated…h".to_string(), + text: "Warning: truncated output (original token count: 2)\nTotal output lines: 1\n\na…6 chars truncated…h".to_string(), }, FunctionCallOutputContentItem::EncryptedContent { encrypted_content: "enc_opaque".to_string(), @@ -322,7 +322,7 @@ fn formatted_truncate_text_content_items_with_policy_merges_all_text_for_token_b assert_eq!( output, vec![FunctionCallOutputContentItem::InputText { - text: "Total output lines: 2\n\nabcd…3 tokens truncated…mnop".to_string(), + text: "Warning: truncated output (original token count: 5)\nTotal output lines: 2\n\nabcd…3 tokens truncated…mnop".to_string(), }] ); assert_eq!(original_token_count, Some(5)); 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 0304777769e3..3e33f316ca05 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -1,4 +1,6 @@ +use crate::PathConvention; use crate::PathUri; +use crate::is_windows_separator_byte; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -30,9 +32,9 @@ use ts_rs::TS; #[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, TS)] #[serde(transparent)] #[ts(type = "string")] -pub struct ApiPathString(String); +pub struct LegacyAppPathString(String); -impl ApiPathString { +impl LegacyAppPathString { /// 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()) @@ -48,7 +50,7 @@ impl ApiPathString { pub fn from_path_uri( path: &PathUri, convention: PathConvention, - ) -> Result { + ) -> Result { if let Some(path_bytes) = path.opaque_fallback_bytes() { return render_opaque_fallback(path, &path_bytes, convention).map(Self); } @@ -61,17 +63,28 @@ impl ApiPathString { /// Parses this API string as an absolute path using the requested native /// path convention and returns its canonical path URI. - pub fn to_path_uri(&self, convention: PathConvention) -> Result { - let path = match convention { - PathConvention::Posix => parse_posix_path(&self.0), - PathConvention::Windows => parse_windows_path(&self.0), - }; - path.ok_or_else(|| ApiPathStringError::InvalidNativePath { - path: self.0.clone(), - convention, + pub fn to_path_uri( + &self, + convention: PathConvention, + ) -> Result { + PathUri::from_absolute_native_path(&self.0, convention).ok_or_else(|| { + LegacyAppPathStringError::InvalidNativePath { + path: self.0.clone(), + convention: Some(convention), + } }) } + /// Parses this API string as an absolute path using the convention inferred from its spelling. + pub fn to_inferred_path_uri(&self) -> Option { + PathUri::try_from(self.clone()).ok() + } + + /// Parses this API string as a host-native absolute path. + pub fn to_inferred_abs_path(&self) -> Option { + AbsolutePathBuf::try_from(self.clone()).ok() + } + /// Infers the path convention of an absolute API path from its spelling. /// /// Relative paths and ambiguous spellings return `None`. In particular, @@ -102,99 +115,55 @@ impl ApiPathString { } } -impl From for ApiPathString { +impl From for LegacyAppPathString { fn from(path: AbsolutePathBuf) -> Self { Self::from_abs_path(&path) } } -fn parse_posix_path(path: &str) -> Option { - let path = path.strip_prefix('/')?; - if path.contains('\0') { - return Some(PathUri::from_opaque_path_bytes( - format!("/{path}").as_bytes(), - )); +impl From for LegacyAppPathString { + fn from(path: PathUri) -> Self { + Self(path.inferred_native_path_string()) } - path_uri_from_segments(/*host*/ None, path.split('/')) } -fn parse_windows_path(path: &str) -> Option { - let bytes = path.as_bytes(); - let uses_namespace = matches!( - bytes, - [first, second, namespace @ (b'.' | b'?'), separator, ..] - if is_windows_separator_byte(*first) - && is_windows_separator_byte(*second) - && is_windows_separator_byte(*separator) - && matches!(*namespace, b'.' | b'?') - ); - if uses_namespace || path.contains('\0') { - return Some(windows_opaque_path_uri(path)); - } - - if matches!( - bytes, - [drive, b':', separator, ..] - if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) - ) { - return path_uri_from_segments( - /*host*/ None, - std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)), - ); - } +impl TryFrom for PathUri { + type Error = LegacyAppPathStringError; - if matches!(bytes, [first, second, ..] - if is_windows_separator_byte(*first) && is_windows_separator_byte(*second)) - { - let mut components = path[2..].split(is_windows_separator_char); - let host = components.next().filter(|host| !host.is_empty())?; - let share = components.next().filter(|share| !share.is_empty())?; - return path_uri_from_segments(Some(host), std::iter::once(share).chain(components)) - .or_else(|| Some(windows_opaque_path_uri(path))); + fn try_from(path: LegacyAppPathString) -> Result { + let Some(convention) = path.infer_absolute_path_convention() else { + return Err(LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: None, + }); + }; + PathUri::from_absolute_native_path(path.as_str(), convention).ok_or({ + LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: Some(convention), + } + }) } - - None } -fn path_uri_from_segments<'a>( - host: Option<&str>, - segments: impl Iterator, -) -> Option { - let mut url = url::Url::parse("file:///").ok()?; - if let Some(host) = host { - url.set_host(Some(host)).ok()?; - } - { - let mut url_segments = url.path_segments_mut().ok()?; - url_segments.clear(); - for segment in segments { - url_segments.push(segment); - } - } - PathUri::try_from(url).ok() -} +impl TryFrom for AbsolutePathBuf { + type Error = LegacyAppPathStringError; -fn windows_opaque_path_uri(path: &str) -> PathUri { - let path_bytes = path - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect::>(); - PathUri::from_opaque_path_bytes(&path_bytes) -} - -fn is_windows_separator_char(character: char) -> bool { - matches!(character, '\\' | '/') -} - -fn is_windows_separator_byte(character: u8) -> bool { - matches!(character, b'\\' | b'/') + fn try_from(path: LegacyAppPathString) -> Result { + AbsolutePathBuf::from_absolute_path_checked(path.as_str()).map_err(|_| { + LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: None, + } + }) + } } fn render_opaque_fallback( path: &PathUri, path_bytes: &[u8], convention: PathConvention, -) -> Result { +) -> Result { let rendered = match convention { PathConvention::Posix if path_bytes.starts_with(b"/") => { Some(String::from_utf8_lossy(path_bytes).into_owned()) @@ -202,7 +171,7 @@ fn render_opaque_fallback( PathConvention::Windows => render_windows_opaque_fallback(path_bytes), PathConvention::Posix => None, }; - rendered.ok_or_else(|| ApiPathStringError::OpaqueFallback { + rendered.ok_or_else(|| LegacyAppPathStringError::OpaqueFallback { path: path.to_string(), }) } @@ -238,13 +207,13 @@ fn is_windows_separator(character: u16) -> bool { character == u16::from(b'\\') || character == u16::from(b'/') } -impl fmt::Display for ApiPathString { +impl fmt::Display for LegacyAppPathString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } -impl Serialize for ApiPathString { +impl Serialize for LegacyAppPathString { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -253,9 +222,9 @@ impl Serialize for ApiPathString { } } -impl JsonSchema for ApiPathString { +impl JsonSchema for LegacyAppPathString { fn schema_name() -> String { - "ApiPathString".to_string() + "LegacyAppPathString".to_string() } fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { @@ -263,7 +232,7 @@ impl JsonSchema for ApiPathString { } } -fn render_posix_path(path: &PathUri) -> Result { +fn render_posix_path(path: &PathUri) -> Result { let url = path.to_url(); // POSIX file paths do not have a UNC authority, so `file://server/share` // cannot be represented as `/share` without losing the server identity. @@ -281,7 +250,7 @@ fn render_posix_path(path: &PathUri) -> Result { Ok(rendered) } -fn render_windows_path(path: &PathUri) -> Result { +fn render_windows_path(path: &PathUri) -> Result { let url = path.to_url(); let mut segments = path_segments(&url); let mut rendered = String::new(); @@ -342,15 +311,15 @@ fn decode_native_segment(segment: &str) -> String { String::from_utf8_lossy(&bytes).into_owned() } -fn incompatible_convention(path: &PathUri, convention: PathConvention) -> ApiPathStringError { - ApiPathStringError::IncompatibleConvention { +fn incompatible_convention(path: &PathUri, convention: PathConvention) -> LegacyAppPathStringError { + LegacyAppPathStringError::IncompatibleConvention { path: path.to_string(), convention, } } #[derive(Debug, Error, PartialEq, Eq)] -pub enum ApiPathStringError { +pub enum LegacyAppPathStringError { #[error("opaque fallback path URI `{path}` cannot be recovered as a native path")] OpaqueFallback { path: String }, #[error("path URI `{path}` cannot be rendered using {convention} path syntax")] @@ -358,48 +327,16 @@ pub enum ApiPathStringError { path: String, convention: PathConvention, }, - #[error("path `{path}` is not absolute using {convention} path syntax")] + #[error( + "path `{path}` is not absolute{convention}", + convention = .convention.map(|convention| format!(" using {convention} path syntax")).unwrap_or_default() + )] InvalidNativePath { path: String, - convention: PathConvention, + convention: Option, }, } -/// Path syntax used to render a [`PathUri`] as an operating-system path. -/// -/// This describes path grammar rather than a specific operating system because -/// Linux and macOS share the POSIX representation relevant here. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -pub enum PathConvention { - Posix, - Windows, -} - -impl PathConvention { - /// Returns the path convention used by the current process. - #[cfg(windows)] - pub const fn native() -> Self { - Self::Windows - } - - /// Returns the path convention used by the current process. - #[cfg(unix)] - pub const fn native() -> Self { - Self::Posix - } -} - -impl fmt::Display for PathConvention { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Posix => f.write_str("POSIX"), - Self::Windows => f.write_str("Windows"), - } - } -} - #[cfg(test)] #[path = "api_path_string_tests.rs"] mod tests; 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 3fbdb3dec0cc..3439ca11eac4 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 @@ -301,21 +301,23 @@ fn renders_native_paths_from_shared_cases() { for case in RENDER_CASES { let path = PathUri::parse(case.uri).expect("valid file URI"); let expected = match case.expected { - RenderExpectation::RoundTrip(rendered) => Ok(ApiPathString(rendered.to_string())), - RenderExpectation::RenderOnly(rendered) => Ok(ApiPathString(rendered.to_string())), + RenderExpectation::RoundTrip(rendered) => Ok(LegacyAppPathString(rendered.to_string())), + RenderExpectation::RenderOnly(rendered) => { + Ok(LegacyAppPathString(rendered.to_string())) + } RenderExpectation::Error(ExpectedError::OpaqueFallback) => { - Err(ApiPathStringError::OpaqueFallback { + Err(LegacyAppPathStringError::OpaqueFallback { path: path.to_string(), }) } RenderExpectation::Error(ExpectedError::IncompatibleConvention) => { - Err(ApiPathStringError::IncompatibleConvention { + Err(LegacyAppPathStringError::IncompatibleConvention { path: path.to_string(), convention: case.convention, }) } }; - let actual = ApiPathString::from_path_uri(&path, case.convention); + let actual = LegacyAppPathString::from_path_uri(&path, case.convention); assert_eq!(actual, expected, "rendering {case:?}"); if let Ok(rendered) = &actual { @@ -327,14 +329,15 @@ fn renders_native_paths_from_shared_cases() { } if let RenderExpectation::RoundTrip(rendered) = case.expected { - let api_path = serde_json::from_value::(serde_json::json!(rendered)) - .expect("native path should deserialize from API text"); + let api_path = + serde_json::from_value::(serde_json::json!(rendered)) + .expect("native path should deserialize from API text"); let reparsed = api_path .to_path_uri(case.convention) .expect("native path should parse using its convention"); assert_eq!(reparsed, path, "parsing {case:?}"); assert_eq!( - ApiPathString::from_path_uri(&reparsed, case.convention), + LegacyAppPathString::from_path_uri(&reparsed, case.convention), Ok(api_path), "round-tripping {case:?}" ); @@ -345,7 +348,7 @@ fn renders_native_paths_from_shared_cases() { #[test] fn relative_api_path_serializes_and_deserializes_unchanged() { for raw_path in [".", "subdir", "subdir/file.rs"] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("relative API path should deserialize"); assert_eq!( @@ -358,15 +361,29 @@ fn relative_api_path_serializes_and_deserializes_unchanged() { #[test] fn relative_api_path_is_invalid_when_converted_to_a_path_uri() { let raw_path = "subdir"; - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("relative API path should deserialize"); assert_eq!(path.infer_absolute_path_convention(), None); assert_eq!( path.to_path_uri(PathConvention::Posix), - Err(ApiPathStringError::InvalidNativePath { + Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), - convention: PathConvention::Posix, + convention: Some(PathConvention::Posix), + }) + ); + assert_eq!( + PathUri::try_from(path.clone()), + Err(LegacyAppPathStringError::InvalidNativePath { + path: raw_path.to_string(), + convention: None, + }) + ); + assert_eq!( + AbsolutePathBuf::try_from(path), + Err(LegacyAppPathStringError::InvalidNativePath { + path: raw_path.to_string(), + convention: None, }) ); } @@ -377,15 +394,15 @@ fn other_non_absolute_api_paths_cannot_be_converted_to_path_uris() { (r"workspace\file.rs", PathConvention::Windows), (r"C:file.rs", PathConvention::Windows), ] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("API path should deserialize without validation"); assert_eq!(path.infer_absolute_path_convention(), None); assert_eq!( path.to_path_uri(convention), - Err(ApiPathStringError::InvalidNativePath { + Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), - convention, + convention: Some(convention), }) ); } @@ -409,7 +426,7 @@ fn infers_absolute_path_conventions_from_api_text() { (r"C:file.rs", None), (r"\rooted-without-drive", None), ] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("API path should deserialize without validation"); assert_eq!( @@ -420,13 +437,58 @@ fn infers_absolute_path_conventions_from_api_text() { } } +#[test] +fn converts_absolute_api_paths_using_the_inferred_convention() { + for (raw_path, convention, expected_uri) in [ + ( + r"C:\workspace\file.rs", + PathConvention::Windows, + "file:///C:/workspace/file.rs", + ), + ( + "/workspace/file.rs", + PathConvention::Posix, + "file:///workspace/file.rs", + ), + ] { + let path = serde_json::from_value::(serde_json::json!(raw_path)) + .expect("absolute API path should deserialize"); + + assert_eq!( + path.to_inferred_path_uri(), + Some(PathUri::parse(expected_uri).expect("expected URI should parse")), + ); + assert_eq!( + PathUri::try_from(path.clone()), + path.to_path_uri(convention) + ); + } +} + +#[test] +fn converts_native_api_path_to_inferred_absolute_path() { + #[cfg(windows)] + let raw_path = r"C:\workspace\file.rs"; + #[cfg(not(windows))] + let raw_path = "/workspace/file.rs"; + let path = serde_json::from_value::(serde_json::json!(raw_path)) + .expect("absolute API path should deserialize"); + let expected = AbsolutePathBuf::try_from(raw_path).expect("native absolute path should parse"); + + assert_eq!( + AbsolutePathBuf::try_from(path.clone()), + Ok(expected.clone()) + ); + assert_eq!(path.to_inferred_abs_path(), Some(expected)); +} + #[test] fn foreign_absolute_syntax_deserializes_without_host_interpretation() { for (raw_path, convention) in [ (r"C:\workspace\file.rs", PathConvention::Windows), ("/workspace/file.rs", PathConvention::Posix), ] { - let path = serde_json::from_value::(serde_json::json!(raw_path)) + let path = serde_json::from_value::(serde_json::json!(raw_path)) .expect("foreign API path should deserialize"); assert_eq!(path.as_str(), raw_path); @@ -444,8 +506,8 @@ fn renders_an_absolute_path_using_the_host_convention() { .expect("native path should be absolute"); assert_eq!( - ApiPathString::from(path), - ApiPathString(native_path.to_string()) + LegacyAppPathString::from(path), + LegacyAppPathString(native_path.to_string()) ); } @@ -464,19 +526,19 @@ fn renders_native_non_unicode_windows_fallback_lossily() { AbsolutePathBuf::from_absolute_path_checked(native_path).expect("absolute native path"); assert_eq!( - ApiPathString::from_abs_path(&native_path), - ApiPathString(r"C:\bad\�".to_string()) + LegacyAppPathString::from_abs_path(&native_path), + LegacyAppPathString(r"C:\bad\�".to_string()) ); let path = PathUri::from_abs_path(&native_path); assert_eq!( - ApiPathString::from_path_uri(&path, PathConvention::Windows), - Ok(ApiPathString(r"C:\bad\�".to_string())) + LegacyAppPathString::from_path_uri(&path, PathConvention::Windows), + Ok(LegacyAppPathString(r"C:\bad\�".to_string())) ); assert_eq!( - ApiPathString::from_path_uri(&path, PathConvention::Posix), - Err(ApiPathStringError::OpaqueFallback { + LegacyAppPathString::from_path_uri(&path, PathConvention::Posix), + Err(LegacyAppPathStringError::OpaqueFallback { path: path.to_string(), }) ); @@ -485,13 +547,13 @@ fn renders_native_non_unicode_windows_fallback_lossily() { #[test] fn serializes_and_deserializes_as_a_string() { let path = PathUri::parse("file:///workspace/src/lib.rs").expect("valid file URI"); - let rendered = ApiPathString::from_path_uri(&path, PathConvention::Posix) + let rendered = LegacyAppPathString::from_path_uri(&path, PathConvention::Posix) .expect("POSIX URI should render"); let json = serde_json::to_string(&rendered).expect("rendered path should serialize"); assert_eq!(json, r#""/workspace/src/lib.rs""#); assert_eq!( - serde_json::from_str::(&json) + serde_json::from_str::(&json) .expect("rendered path should deserialize from a string"), rendered ); diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index e9a00543fc18..5ef1df2fa24f 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -12,6 +12,7 @@ use serde::Serializer; use std::fmt; use std::io; use std::path::Path; +use std::path::PathBuf; use std::str::FromStr; use thiserror::Error; use ts_rs::TS; @@ -19,9 +20,8 @@ use url::Url; mod api_path_string; -pub use api_path_string::ApiPathString; -pub use api_path_string::ApiPathStringError; -pub use api_path_string::PathConvention; +pub use api_path_string::LegacyAppPathString; +pub use api_path_string::LegacyAppPathStringError; pub const FILE_SCHEME: &str = "file"; const BAD_PATH_URI_PREFIX: &str = "file:///%00/bad/path/"; @@ -97,6 +97,17 @@ impl PathUri { Self::from_opaque_path_bytes(&path_bytes) } + /// Parses an absolute native path using the specified path convention. + pub(crate) fn from_absolute_native_path( + path: &str, + convention: PathConvention, + ) -> Option { + match convention { + PathConvention::Posix => parse_posix_path(path), + PathConvention::Windows => parse_windows_path(path), + } + } + fn from_opaque_path_bytes(path_bytes: &[u8]) -> Self { let encoded_path = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(path_bytes); let Ok(uri) = Self::parse(&format!("{BAD_PATH_URI_PREFIX}{encoded_path}")) else { @@ -164,6 +175,19 @@ impl PathUri { } } + /// Renders this URI using the native path syntax inferred from its shape. + /// + /// This is independent of the current host: a Windows URI renders with + /// Windows separators on every host. If the convention cannot be inferred + /// or the URI cannot be represented using that convention, the canonical + /// URI string is returned instead. + pub fn inferred_native_path_string(&self) -> String { + self.infer_path_convention() + .and_then(|convention| LegacyAppPathString::from_path_uri(self, convention).ok()) + .map(LegacyAppPathString::into_string) + .unwrap_or_else(|| self.to_string()) + } + /// Returns the decoded final URI path segment, or `None` for the URI root /// or an opaque fallback URI created by [`Self::from_abs_path`]. /// @@ -180,13 +204,33 @@ impl PathUri { .map(decode_uri_path) } - /// Returns the parent URI, or `None` for the URI root or an opaque fallback - /// URI created by [`Self::from_abs_path`]. + /// Renders this URI as a path-flavored string using its inferred convention. + pub fn to_path_buf(&self) -> PathBuf { + PathBuf::from(self.inferred_native_path_string()) + } + + /// Returns the lexical parent without crossing the inferred native path root. + /// + /// POSIX `/`, Windows drive roots, Windows UNC share roots, and opaque fallback + /// URIs created by [`Self::from_abs_path`] have no parent. pub fn parent(&self) -> Option { - if self.encoded_path() == "/" || decode_bad_path_uri(&self.0).is_some() { + if decode_bad_path_uri(&self.0).is_some() { return None; } + let convention = self.infer_path_convention()?; + // In URI form, both a Windows drive root (`file:///C:`) and a UNC share root + // (`file://server/share`) retain one non-empty path segment. Keep that segment as the + // anchor so parent traversal cannot produce a URI that is not an absolute Windows path. + let anchor_depth = usize::from(convention == PathConvention::Windows); + let depth = self + .0 + .path_segments()? + .filter(|segment| !segment.is_empty()) + .count(); + if depth <= anchor_depth { + return None; + } let mut url = self.0.clone(); { let mut segments = match url.path_segments_mut() { @@ -198,42 +242,92 @@ impl PathUri { Some(Self(url)) } - /// Lexically joins a relative URI path onto this URI. + /// Returns this URI and each lexical parent up to its inferred native path root. + pub fn ancestors(&self) -> impl Iterator { + std::iter::successors(Some(self.clone()), Self::parent) + } + + /// Lexically resolves native absolute or relative path text against this URI. /// + /// Path text is interpreted using the POSIX or Windows convention inferred + /// from the base URI. An absolute path replaces the base URI's path, while a + /// relative path is appended lexically. Windows root-relative paths retain + /// the base drive or UNC share, while drive-relative paths are rejected. /// Empty and `.` segments are ignored, while `..` removes one segment - /// without escaping the URI root. Literal `%`, `?`, and `#` characters are - /// percent-encoded as filename text. Paths containing a null character are - /// rejected because they cannot be safely converted to native paths. + /// without escaping the POSIX root, Windows drive, or UNC share. Literal + /// `%`, `?`, and `#` characters are percent-encoded as filename text. Paths + /// containing a null character are rejected because they cannot be safely + /// converted to native paths. /// Opaque fallback URIs created by [`Self::from_abs_path`] reject non-empty /// joins. pub fn join(&self, path: &str) -> Result { - if path.starts_with('/') { - return Err(PathUriParseError::JoinPathMustBeRelative(path.to_string())); - } if path.contains('\0') { - return Err(PathUriParseError::InvalidFileUriPath); + return Err(PathUriParseError::InvalidFileUriPath { + path: path.to_string(), + }); } if path.is_empty() { return Ok(self.clone()); } + let convention = + self.infer_path_convention() + .ok_or_else(|| PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + })?; + // An absolute native path is already fully resolved, so replace the base URI's main path + // instead of appending it. + if let Some(absolute) = Self::from_absolute_native_path(path, convention) { + return Ok(absolute); + } + let path_bytes = path.as_bytes(); + if convention == PathConvention::Windows + && matches!(path_bytes, [drive, b':', ..] if drive.is_ascii_alphabetic()) + { + return Err(PathUriParseError::InvalidFileUriPath { + path: path.to_string(), + }); + } if decode_bad_path_uri(&self.0).is_some() { - return Err(PathUriParseError::InvalidFileUriPath); + return Err(PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + }); } let mut url = self.0.clone(); + let anchor_depth = usize::from(convention == PathConvention::Windows); + let mut depth = url + .path_segments() + .map(|segments| segments.filter(|segment| !segment.is_empty()).count()) + .unwrap_or_default(); + let windows_root_relative = convention == PathConvention::Windows + && matches!(path_bytes, [b'\\' | b'/', rest @ ..] if !matches!(rest, [b'\\' | b'/', ..])); { let Ok(mut segments) = url.path_segments_mut() else { unreachable!("validated file URLs support hierarchical path segments"); }; segments.pop_if_empty(); + if windows_root_relative { + while depth > anchor_depth { + segments.pop(); + depth -= 1; + } + } + let path = match convention { + PathConvention::Posix => path.to_string(), + PathConvention::Windows => path.replace('\\', "/"), + }; for component in path.split('/') { match component { "" | "." => {} ".." => { - segments.pop(); + if depth > anchor_depth { + segments.pop(); + depth -= 1; + } } component => { segments.push(component); + depth += 1; } } } @@ -243,15 +337,19 @@ impl PathUri { /// Converts this file URI to a path using the current host's path rules. /// - /// Conversion should succeed when the URI was created from an - /// [`AbsolutePathBuf`] on the current host, including fallback URIs created - /// by [`Self::from_abs_path`]. It may fail when the URI came from a different - /// operating system and its `file:` URI form cannot be represented using - /// the current host's path rules, such as a UNC authority on POSIX or a - /// POSIX root on Windows. Because a `file:` URI does not record its source - /// operating system, callers should only use this method when the URI is - /// known to identify a path on the current host. + /// The URI's inferred path convention must match the current host. Conversion should succeed + /// when the URI was created from an [`AbsolutePathBuf`] on the current host, including fallback + /// URIs created by [`Self::from_abs_path`]. Foreign conventions are rejected rather than being + /// projected onto a syntactically valid but unrelated host path. pub fn to_abs_path(&self) -> io::Result { + if self.infer_path_convention() != Some(PathConvention::native()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + }, + )); + } if let Some(path_bytes) = decode_bad_path_uri(&self.0) { #[cfg(unix)] let decoded_path = { @@ -280,18 +378,28 @@ impl PathUri { return Err(io::Error::new( io::ErrorKind::InvalidInput, - PathUriParseError::InvalidFileUriPath, + PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + }, )); } let path = self.0.to_file_path().map_err(|()| { io::Error::new( io::ErrorKind::InvalidInput, - PathUriParseError::InvalidFileUriPath, + PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + }, ) })?; - AbsolutePathBuf::from_absolute_path_checked(path) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err)) + AbsolutePathBuf::from_absolute_path_checked(path).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + }, + ) + }) } /// Returns a clone of the canonical URL. @@ -323,6 +431,12 @@ impl TryFrom for PathUri { } } +impl From for PathUri { + fn from(p: AbsolutePathBuf) -> Self { + Self::from_abs_path(&p) + } +} + impl<'de> Deserialize<'de> for PathUri { fn deserialize(deserializer: D) -> Result where @@ -447,6 +561,88 @@ fn infer_opaque_path_convention(path_bytes: &[u8]) -> Option { (has_drive || has_unc_prefix).then_some(PathConvention::Windows) } +fn parse_posix_path(path: &str) -> Option { + let path = path.strip_prefix('/')?; + if path.contains('\0') { + return Some(PathUri::from_opaque_path_bytes( + format!("/{path}").as_bytes(), + )); + } + path_uri_from_segments(/*host*/ None, path.split('/')) +} + +fn parse_windows_path(path: &str) -> Option { + let bytes = path.as_bytes(); + let uses_namespace = matches!( + bytes, + [first, second, namespace @ (b'.' | b'?'), separator, ..] + if is_windows_separator_byte(*first) + && is_windows_separator_byte(*second) + && is_windows_separator_byte(*separator) + && matches!(*namespace, b'.' | b'?') + ); + if uses_namespace || path.contains('\0') { + return Some(windows_opaque_path_uri(path)); + } + + if matches!( + bytes, + [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) + ) { + return path_uri_from_segments( + /*host*/ None, + std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)), + ); + } + + if matches!(bytes, [first, second, ..] + if is_windows_separator_byte(*first) && is_windows_separator_byte(*second)) + { + let mut components = path[2..].split(is_windows_separator_char); + let host = components.next().filter(|host| !host.is_empty())?; + let share = components.next().filter(|share| !share.is_empty())?; + return path_uri_from_segments(Some(host), std::iter::once(share).chain(components)) + .or_else(|| Some(windows_opaque_path_uri(path))); + } + + None +} + +fn path_uri_from_segments<'a>( + host: Option<&str>, + segments: impl Iterator, +) -> Option { + let mut url = Url::parse("file:///").ok()?; + if let Some(host) = host { + url.set_host(Some(host)).ok()?; + } + { + let mut url_segments = url.path_segments_mut().ok()?; + url_segments.clear(); + for segment in segments { + url_segments.push(segment); + } + } + PathUri::try_from(url).ok() +} + +fn windows_opaque_path_uri(path: &str) -> PathUri { + let path_bytes = path + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + PathUri::from_opaque_path_bytes(&path_bytes) +} + +fn is_windows_separator_char(character: char) -> bool { + matches!(character, '\\' | '/') +} + +pub(crate) fn is_windows_separator_byte(character: u8) -> bool { + matches!(character, b'\\' | b'/') +} + /// Rejects URI metadata that has no defined meaning for `file:` URIs. fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> { if !url.username().is_empty() || url.password().is_some() { @@ -472,7 +668,9 @@ fn validate_file_url(url: &Url) -> Result<(), PathUriParseError> { if urlencoding::decode_binary(url.path().as_bytes()).contains(&0) && decode_bad_path_uri(url).is_none() { - return Err(PathUriParseError::InvalidFileUriPath); + return Err(PathUriParseError::InvalidFileUriPath { + path: url.to_string(), + }); } Ok(()) } @@ -483,8 +681,8 @@ pub enum PathUriParseError { InvalidUri(#[from] url::ParseError), #[error("unsupported path URI scheme `{0}`")] UnsupportedScheme(String), - #[error("file URI contains an invalid absolute path")] - InvalidFileUriPath, + #[error("'{path}' is invalid on '{os}'", os = std::env::consts::OS)] + InvalidFileUriPath { path: String }, #[error("credentials are not allowed in path URIs")] CredentialsNotAllowed, #[error("ports are not allowed in path URIs")] @@ -497,6 +695,52 @@ pub enum PathUriParseError { JoinPathMustBeRelative(String), } +/// Path syntax used to render a [`PathUri`] as an operating-system path. +/// +/// This describes path grammar rather than a specific operating system because +/// Linux and macOS share the POSIX representation relevant here. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum PathConvention { + Posix, + Windows, +} + +impl PathConvention { + /// Returns the path convention used by the current process. + #[cfg(windows)] + pub const fn native() -> Self { + Self::Windows + } + + /// Returns the path convention used by the current process. + #[cfg(unix)] + pub const fn native() -> Self { + Self::Posix + } + + /// Splits absolute or relative native path text into lexical segments. + /// + /// This does not validate the path or require it to be absolute. POSIX paths split on `/`, + /// while Windows paths split on both `\\` and `/`. Empty segments are retained. + pub fn path_segments(self, path: &str) -> impl DoubleEndedIterator { + path.split(move |character| match self { + Self::Posix => character == '/', + Self::Windows => matches!(character, '/' | '\\'), + }) + } +} + +impl fmt::Display for PathConvention { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Posix => f.write_str("POSIX"), + Self::Windows => f.write_str("Windows"), + } + } +} + #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index fa0c5d8b0a06..075d343d0198 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -34,15 +34,24 @@ fn file_uri_round_trips_an_absolute_path() { #[test] fn non_native_uri_io_conversion_is_invalid_input() { #[cfg(unix)] - let uri = PathUri::parse("file://server/share/file.txt").expect("valid file URI"); + let uris = ["file://server/share/file.txt", "file:///C:/workspace"]; #[cfg(windows)] - let uri = PathUri::parse("file:///usr/local/file.txt").expect("valid file URI"); + let uris = ["file:///usr/local/file.txt"]; - let error = uri - .to_abs_path() - .expect_err("URI should not be host-native"); + for uri in uris { + let uri = PathUri::parse(uri).expect("valid file URI"); + let error = uri + .to_abs_path() + .expect_err("URI should not be host-native"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + (error.kind(), error.to_string()), + ( + io::ErrorKind::InvalidInput, + format!("'{uri}' is invalid on '{}'", std::env::consts::OS), + ) + ); + } } #[test] @@ -84,6 +93,35 @@ fn infers_path_conventions_from_uri_shape() { } } +#[test] +fn path_convention_splits_absolute_relative_and_bare_path_text() { + for (convention, path, expected) in [ + ( + PathConvention::Posix, + "/usr/local/bin/bash", + vec!["", "usr", "local", "bin", "bash"], + ), + ( + PathConvention::Posix, + r"tools\pwsh.exe", + vec![r"tools\pwsh.exe"], + ), + ( + PathConvention::Windows, + r"C:\Program Files\PowerShell\7\pwsh.exe", + vec!["C:", "Program Files", "PowerShell", "7", "pwsh.exe"], + ), + ( + PathConvention::Windows, + "tools/pwsh.exe", + vec!["tools", "pwsh.exe"], + ), + (PathConvention::Windows, "cmd.exe", vec!["cmd.exe"]), + ] { + assert_eq!(convention.path_segments(path).collect::>(), expected); + } +} + #[test] fn drive_shaped_posix_uri_is_intentionally_inferred_as_windows() { let path = PathUri::parse("file:///C:/actually/a/posix/path").expect("valid path URI"); @@ -94,6 +132,33 @@ fn drive_shaped_posix_uri_is_intentionally_inferred_as_windows() { assert_eq!(path.infer_path_convention(), Some(PathConvention::Windows)); } +#[test] +fn inferred_native_path_string_uses_the_inferred_convention() { + for (uri, expected) in [ + ("file:///home/alice/a%20file.rs", "/home/alice/a file.rs"), + ( + "file:///C:/Users/Alice%20Smith/main.rs", + r"C:\Users\Alice Smith\main.rs", + ), + ("file://server/share/main.rs", r"\\server\share\main.rs"), + ("file://server/", "file://server/"), + ("file:///%00/bad/path/YQ", "file:///%00/bad/path/YQ"), + ] { + let path = PathUri::parse(uri).expect("valid path URI"); + + assert_eq!( + path.inferred_native_path_string(), + expected, + "rendering {uri}" + ); + assert_eq!( + LegacyAppPathString::from(path).as_str(), + expected, + "rendering typed API path {uri}" + ); + } +} + #[cfg(windows)] #[test] fn file_uri_falls_back_for_windows_prefixes_without_a_uri_representation() { @@ -193,7 +258,9 @@ fn malformed_bad_path_uris_are_rejected() { ] { assert_eq!( PathUri::parse(uri), - Err(PathUriParseError::InvalidFileUriPath), + Err(PathUriParseError::InvalidFileUriPath { + path: uri.to_string(), + }), "parsing {uri}" ); } @@ -222,7 +289,9 @@ fn bad_path_uris_are_opaque_to_lexical_operations() { assert_eq!(uri.join(""), Ok(uri.clone())); assert_eq!( uri.join("child"), - Err(PathUriParseError::InvalidFileUriPath) + Err(PathUriParseError::InvalidFileUriPath { + path: uri.to_string(), + }) ); } @@ -439,7 +508,21 @@ fn basename_uses_decoded_uri_segments() { } #[test] -fn parent_uses_uri_hierarchy_and_preserves_authority() { +fn path_buf_uses_the_inferred_native_spelling() { + let windows = PathUri::parse("file:///C:/Program%20Files/pwsh.exe").expect("Windows URI"); + let posix = PathUri::parse("file:///usr/local/bin/bash").expect("POSIX URI"); + + assert_eq!( + (windows.to_path_buf(), posix.to_path_buf()), + ( + PathBuf::from(r"C:\Program Files\pwsh.exe"), + PathBuf::from("/usr/local/bin/bash"), + ) + ); +} + +#[test] +fn parent_stops_at_posix_drive_and_unc_roots() { for (input, expected) in [ ( "file:///workspace/src/lib.rs", @@ -448,12 +531,13 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() { ("file:///workspace", Some("file:///")), ("file:///", None), ("file:///C:/Users", Some("file:///C:")), - ("file:///C:/", Some("file:///")), + ("file:///C:/", None), + ("file:///C:", None), ( "file://server/share/src/main.rs", Some("file://server/share/src"), ), - ("file://server/share", Some("file://server/")), + ("file://server/share", None), ] { let uri = PathUri::parse(input).expect("valid file URI"); let expected = expected.map(|value| PathUri::parse(value).expect("valid expected URI")); @@ -461,6 +545,35 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() { } } +#[test] +fn ancestors_include_self_and_stop_at_native_path_roots() { + for (input, expected) in [ + ( + "file:///workspace/src", + vec!["file:///workspace/src", "file:///workspace", "file:///"], + ), + ( + "file:///C:/workspace/src", + vec![ + "file:///C:/workspace/src", + "file:///C:/workspace", + "file:///C:", + ], + ), + ( + "file://server/share/project", + vec!["file://server/share/project", "file://server/share"], + ), + ] { + let uri = PathUri::parse(input).expect("valid file URI"); + let ancestors = uri + .ancestors() + .map(|path| path.to_string()) + .collect::>(); + assert_eq!(ancestors, expected, "ancestors for {input}"); + } +} + #[test] fn join_normalizes_relative_uri_segments() { for (base, relative, expected) in [ @@ -490,17 +603,98 @@ fn join_normalizes_relative_uri_segments() { } #[test] -fn join_rejects_absolute_and_null_paths() { +fn join_replaces_posix_absolute_path() { let base = PathUri::parse("file:///workspace").expect("valid base URI"); - assert!(matches!( + assert_eq!( base.join("/src"), - Err(PathUriParseError::JoinPathMustBeRelative(path)) if path == "/src" - )); - assert!(matches!( + Ok(PathUri::parse("file:///src").expect("valid absolute URI")) + ); +} + +#[test] +fn join_replaces_windows_absolute_path() { + let base = PathUri::parse("file:///C:/workspace/src").expect("valid base URI"); + + assert_eq!( + base.join(r"D:\tmp\test.rs"), + Ok(PathUri::parse("file:///D:/tmp/test.rs").expect("valid absolute URI")) + ); +} + +#[test] +fn join_windows_root_relative_path_preserves_drive_or_share() { + for (base, path, expected) in [ + ("file:///C:/base/dir", r"\Windows", "file:///C:/Windows"), + ( + "file://server/share/base/dir", + r"\Windows", + "file://server/share/Windows", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(path), Ok(expected), "joining {path}"); + } +} + +#[test] +fn join_rejects_windows_drive_relative_path() { + let base = PathUri::parse("file:///C:/base").expect("valid base URI"); + + assert_eq!( + base.join(r"D:tmp"), + Err(PathUriParseError::InvalidFileUriPath { + path: r"D:tmp".to_string(), + }) + ); +} + +#[test] +fn join_parent_segments_preserve_windows_drive_or_share_anchor() { + for (base, expected) in [ + ("file:///C:/base/dir", "file:///C:/Windows"), + ( + "file://server/share/base/dir", + "file://server/share/Windows", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(r"..\..\..\Windows"), Ok(expected)); + } +} + +#[test] +fn join_rejects_null_paths() { + let base = PathUri::parse("file:///workspace").expect("valid base URI"); + + assert_eq!( base.join("src\0file"), - Err(PathUriParseError::InvalidFileUriPath) - )); + Err(PathUriParseError::InvalidFileUriPath { + path: "src\0file".to_string(), + }) + ); +} + +#[test] +fn join_uses_the_base_uri_path_convention() { + for (base, path, expected) in [ + ( + "file:///workspace/src", + "../tests/test.rs", + "file:///workspace/tests/test.rs", + ), + ( + "file:///C:/workspace/src", + r"..\tests\test.rs", + "file:///C:/workspace/tests/test.rs", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(path), Ok(expected), "joining {path}"); + } } #[test] diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml index 2c56ab37738e..cdfb600c8c20 100644 --- a/codex-rs/utils/plugins/Cargo.toml +++ b/codex-rs/utils/plugins/Cargo.toml @@ -15,7 +15,6 @@ workspace = true [dependencies] codex-exec-server = { workspace = true } -codex-login = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index fe178e679c3b..203b1f22e41d 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -15,5 +15,6 @@ pub use plugin_namespace::plugin_namespace_for_skill_path; pub struct PluginSkillRoot { pub path: AbsolutePathBuf, pub plugin_id: String, + pub plugin_namespace: String, pub plugin_root: AbsolutePathBuf, } diff --git a/codex-rs/utils/plugins/src/mcp_connector.rs b/codex-rs/utils/plugins/src/mcp_connector.rs index 0c293936c9a9..96fd72eb24b1 100644 --- a/codex-rs/utils/plugins/src/mcp_connector.rs +++ b/codex-rs/utils/plugins/src/mcp_connector.rs @@ -1,31 +1,3 @@ -use codex_login::default_client::is_first_party_chat_originator; -use codex_login::default_client::originator; - -const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ - "asdk_app_6938a94a61d881918ef32cb999ff937c", - "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - "connector_3f8d1a79f27c4c7ba1a897ab13bf37dc", - "connector_68de829bf7648191acd70a907364c67c", - "connector_68e004f14af881919eb50893d3d9f523", - "connector_69272cb413a081919685ec3c88d1744e", -]; -const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = - &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; - -pub fn is_connector_id_allowed(connector_id: &str) -> bool { - is_connector_id_allowed_for_originator(connector_id, originator().value.as_str()) -} - -fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: &str) -> bool { - let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) { - FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS - } else { - DISALLOWED_CONNECTOR_IDS - }; - - !disallowed_connector_ids.contains(&connector_id) -} - pub fn sanitize_name(name: &str) -> String { sanitize_slug(name).replace("-", "_") } diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index e3376f4055fd..e1146987bf61 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -819,7 +819,7 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res } if refresh_only { - setup_runtime_bin::ensure_codex_app_runtime_bin_readable( + setup_runtime_bin::ensure_codex_app_runtime_paths_readable( sandbox_group_psid, &mut refresh_errors, log, diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs index 47cacf72e0cf..509670e36bdb 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs @@ -10,83 +10,88 @@ use windows_sys::Win32::Security::OBJECT_INHERIT_ACE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; -pub(super) fn ensure_codex_app_runtime_bin_readable( +pub(super) fn ensure_codex_app_runtime_paths_readable( sandbox_group_psid: *mut c_void, refresh_errors: &mut Vec, log: &mut dyn Write, ) -> Result<()> { - let local_app_data = std::env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .or_else(|| { - std::env::var_os("USERPROFILE") - .map(PathBuf::from) - .map(|profile| profile.join("AppData").join("Local")) - }); + let local_app_data = local_app_data_root(); let Some(local_app_data) = local_app_data else { return Ok(()); }; - // Codex desktop copies bundled Windows binaries out of WindowsApps to this - // fixed LocalAppData cache before launching codex.exe. - let runtime_bin_dir = local_app_data.join("OpenAI").join("Codex").join("bin"); - if !runtime_bin_dir.is_dir() { - return Ok(()); - } - let read_execute_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; - let has_access = match path_mask_allows( - &runtime_bin_dir, - &[sandbox_group_psid], - read_execute_mask, - /*require_all_bits*/ true, - ) { - Ok(has_access) => has_access, - Err(err) => { - refresh_errors.push(format!( - "runtime bin read/execute mask check failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - )); - super::log_line( - log, - &format!( - "runtime bin read/execute mask check failed on {} for sandbox_group: {err}; continuing", - runtime_bin_dir.display() - ), - )?; - false + let codex_root = local_app_data.join("OpenAI").join("Codex"); + + for runtime_path in [codex_root.join("bin"), codex_root.join("runtimes")] { + if !runtime_path.is_dir() { + continue; } - }; - if has_access { - return Ok(()); - } - super::log_line( - log, - &format!( - "granting read/execute ACE to {} for sandbox users", - runtime_bin_dir.display() - ), - )?; - let result = unsafe { - ensure_allow_mask_aces_with_inheritance( - &runtime_bin_dir, + let has_access = match path_mask_allows( + &runtime_path, &[sandbox_group_psid], read_execute_mask, - OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, - ) - }; - if let Err(err) = result { - refresh_errors.push(format!( - "grant read/execute ACE failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - )); + /*require_all_bits*/ true, + ) { + Ok(has_access) => has_access, + Err(err) => { + refresh_errors.push(format!( + "runtime read/execute mask check failed on {} for sandbox_group: {err}", + runtime_path.display() + )); + super::log_line( + log, + &format!( + "runtime read/execute mask check failed on {} for sandbox_group: {err}; continuing", + runtime_path.display() + ), + )?; + false + } + }; + if has_access { + continue; + } + super::log_line( log, &format!( - "grant read/execute ACE failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() + "granting read/execute ACE to {} for sandbox users", + runtime_path.display() ), )?; + let result = unsafe { + ensure_allow_mask_aces_with_inheritance( + &runtime_path, + &[sandbox_group_psid], + read_execute_mask, + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + ) + }; + if let Err(err) = result { + refresh_errors.push(format!( + "grant read/execute ACE failed on {} for sandbox_group: {err}", + runtime_path.display() + )); + super::log_line( + log, + &format!( + "grant read/execute ACE failed on {} for sandbox_group: {err}", + runtime_path.display() + ), + )?; + } } Ok(()) } + +fn local_app_data_root() -> Option { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .map(|profile| profile.join("AppData").join("Local")) + }) +} diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index e5b202dede02..bcc09f125091 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -96,22 +96,26 @@ pub fn resolve_current_exe_for_launch(codex_home: &Path, fallback_executable: &s Ok(path) => path, Err(_) => return PathBuf::from(fallback_executable), }; + resolve_exe_for_launch(&source, codex_home) +} + +pub fn resolve_exe_for_launch(source: &Path, codex_home: &Path) -> PathBuf { let Some(file_name) = source.file_name() else { - return source; + return source.to_path_buf(); }; let destination = helper_bin_dir(codex_home).join(file_name); - match copy_from_source_if_needed(&source, &destination) { + match copy_from_source_if_needed(source, &destination) { Ok(_) => destination, Err(err) => { let sandbox_log_dir = crate::sandbox_dir(codex_home); log_note( &format!( - "helper copy failed for current executable: {err:#}; falling back to legacy path {}", + "helper copy failed for executable: {err:#}; falling back to legacy path {}", source.display() ), Some(&sandbox_log_dir), ); - source + source.to_path_buf() } } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 4c1858430b33..a7f3df6e2a12 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -174,6 +174,8 @@ pub use elevated_impl::run_windows_sandbox_capture_for_permission_profile as run #[cfg(target_os = "windows")] pub use helper_materialization::resolve_current_exe_for_launch; #[cfg(target_os = "windows")] +pub use helper_materialization::resolve_exe_for_launch; +#[cfg(target_os = "windows")] pub use hide_users::hide_current_user_profile_dir; #[cfg(target_os = "windows")] pub use hide_users::hide_newly_created_users; diff --git a/docs/agents_md.md b/docs/agents_md.md index 3df0facdf64c..4fa02abd1d78 100644 --- a/docs/agents_md.md +++ b/docs/agents_md.md @@ -1,7 +1,3 @@ # AGENTS.md For information about AGENTS.md, see [this documentation](https://developers.openai.com/codex/guides/agents-md). - -## Hierarchical agents message - -When the `child_agents_md` feature flag is enabled (via `[features]` in `config.toml`), Codex appends additional guidance about AGENTS.md scope and precedence to the user instructions message and emits that message even when no AGENTS.md is present. diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index a431c3f3a61a..ed1413717007 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 5e12bd7d3ba3..0fd33c97667b 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 9f548636b2fc..66cf4e7b3446 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/scripts/install/install.sh b/scripts/install/install.sh index 23980b0c5e68..7513efc42971 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -213,7 +213,7 @@ package_archive_digest() { manifest_path="$2" digest="$(awk -v asset="$asset" ' - $2 == asset && $1 ~ /^[0-9a-fA-F]{64}$/ { + $2 == asset && length($1) == 64 && $1 !~ /[^0-9a-fA-F]/ { print tolower($1) found = 1 exit diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index f003a28511d8..f253185dc540 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 c3237e320e35..8673e455a307 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 @@ 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 @@ 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 f7402f18c98f..1b20f8462d28 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 @@ 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 719fb29a41ae..a86580cc7643 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 fde7a32c00d5..88039f4b9cd3 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 1bb9bf731f60..3f84cf06757b 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 a4af5d605e1f..f3b97c6b6fde 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 a6e5e7b528fb..0a1fbe1100ff 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 15ede1801cf6..c024a2c8c07c 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 56505e481773..29b8fc77267d 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 524260035740..b2cfda53b5e9 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 @@ import pytest import tomllib +from pydantic import ValidationError ROOT = Path(__file__).resolve().parents[1] @@ -306,6 +307,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 +377,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(