mirror of
https://github.com/openai/codex.git
synced 2026-02-04 07:53:43 +00:00
Compare commits
61 Commits
queue/stee
...
dh--apply-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce70dbb5c | ||
|
|
961270022d | ||
|
|
28e8306f6a | ||
|
|
14f85c1d31 | ||
|
|
050382098a | ||
|
|
badd55aae3 | ||
|
|
5675af5190 | ||
|
|
31d9b6f4d2 | ||
|
|
5a82a72d93 | ||
|
|
ce49e92848 | ||
|
|
4d787a2cc2 | ||
|
|
c96c26cf5b | ||
|
|
7e33ac7eb6 | ||
|
|
ebbbee70c6 | ||
|
|
5a70b1568f | ||
|
|
903a0c0933 | ||
|
|
4c673086bc | ||
|
|
2cd1a0a45e | ||
|
|
9f8d3c14ce | ||
|
|
89403c5e11 | ||
|
|
3c711f3d16 | ||
|
|
141d2b5022 | ||
|
|
ebacd28817 | ||
|
|
e25d2ab3bf | ||
|
|
bde734fd1e | ||
|
|
58e8f75b27 | ||
|
|
2651980bdf | ||
|
|
51d75bb80a | ||
|
|
57ba758df5 | ||
|
|
40e2405998 | ||
|
|
fe03320791 | ||
|
|
2d56519ecd | ||
|
|
97f1f20edb | ||
|
|
3b8d79ee11 | ||
|
|
3a300d1117 | ||
|
|
17ab5f6a52 | ||
|
|
3c8fb90bf0 | ||
|
|
325ce985f1 | ||
|
|
18b737910c | ||
|
|
cbca43d57a | ||
|
|
e726a82c8a | ||
|
|
ddae70bd62 | ||
|
|
d75626ad99 | ||
|
|
12779c7c07 | ||
|
|
490c1c1fdd | ||
|
|
87f7226cca | ||
|
|
3a6a43ff5c | ||
|
|
d7cdcfc302 | ||
|
|
5dfa780f3d | ||
|
|
3e91a95ce1 | ||
|
|
034d489c34 | ||
|
|
729e097662 | ||
|
|
7ac498e0e0 | ||
|
|
45ffcdf886 | ||
|
|
06088535ad | ||
|
|
4223948cf5 | ||
|
|
898e5f82f0 | ||
|
|
d5562983d9 | ||
|
|
9659583559 | ||
|
|
623707ab58 | ||
|
|
86f81ca010 |
6
.markdownlint-cli2.yaml
Normal file
6
.markdownlint-cli2.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
config:
|
||||
MD013:
|
||||
line_length: 100
|
||||
|
||||
globs:
|
||||
- "docs/tui-chat-composer.md"
|
||||
@@ -13,6 +13,7 @@ In the codex-rs folder where the rust code lives:
|
||||
- Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls
|
||||
- When writing tests, prefer comparing the equality of entire objects over fields one by one.
|
||||
- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable.
|
||||
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
|
||||
|
||||
Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests:
|
||||
|
||||
|
||||
18
MODULE.bazel.lock
generated
18
MODULE.bazel.lock
generated
@@ -409,8 +409,8 @@
|
||||
"chrono_0.4.42": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.63\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}",
|
||||
"chunked_transfer_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{}}",
|
||||
"cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}",
|
||||
"clap_4.5.53": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.5.53\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.5.49\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.3\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}",
|
||||
"clap_builder_4.5.53": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.7\"},{\"name\":\"anstyle\",\"req\":\"^1.0.8\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.73\"},{\"name\":\"clap_lex\",\"req\":\"^0.7.4\"},{\"kind\":\"dev\",\"name\":\"color-print\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.6.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"cargo\":[],\"color\":[\"dep:anstream\"],\"debug\":[\"dep:backtrace\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[],\"env\":[],\"error-context\":[],\"help\":[],\"std\":[\"anstyle/std\"],\"string\":[],\"suggestions\":[\"dep:strsim\",\"error-context\"],\"unicode\":[\"dep:unicode-width\",\"dep:unicase\"],\"unstable-doc\":[\"cargo\",\"wrap_help\",\"env\",\"unicode\",\"string\",\"unstable-ext\"],\"unstable-ext\":[],\"unstable-styles\":[\"color\"],\"unstable-v5\":[\"deprecated\"],\"usage\":[],\"wrap_help\":[\"help\",\"dep:terminal_size\"]}}",
|
||||
"clap_4.5.54": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.5.54\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.5.49\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.3\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}",
|
||||
"clap_builder_4.5.54": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.7\"},{\"name\":\"anstyle\",\"req\":\"^1.0.8\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.73\"},{\"name\":\"clap_lex\",\"req\":\"^0.7.4\"},{\"kind\":\"dev\",\"name\":\"color-print\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.6.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"cargo\":[],\"color\":[\"dep:anstream\"],\"debug\":[\"dep:backtrace\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[],\"env\":[],\"error-context\":[],\"help\":[],\"std\":[\"anstyle/std\"],\"string\":[],\"suggestions\":[\"dep:strsim\",\"error-context\"],\"unicode\":[\"dep:unicode-width\",\"dep:unicase\"],\"unstable-doc\":[\"cargo\",\"wrap_help\",\"env\",\"unicode\",\"string\",\"unstable-ext\"],\"unstable-ext\":[],\"unstable-styles\":[\"color\"],\"unstable-v5\":[\"deprecated\"],\"usage\":[],\"wrap_help\":[\"help\",\"dep:terminal_size\"]}}",
|
||||
"clap_complete_4.5.64": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"default_features\":false,\"features\":[\"std\",\"derive\",\"help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"name\":\"clap_lex\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"completest\",\"optional\":true,\"req\":\"^0.4.2\"},{\"name\":\"completest-pty\",\"optional\":true,\"req\":\"^0.5.5\"},{\"name\":\"is_executable\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"shlex\",\"optional\":true,\"req\":\"^1.3.0\"},{\"features\":[\"diff\",\"dir\",\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.1\"}],\"features\":{\"debug\":[\"clap/debug\"],\"default\":[],\"unstable-doc\":[\"unstable-dynamic\"],\"unstable-dynamic\":[\"dep:clap_lex\",\"dep:shlex\",\"dep:is_executable\",\"clap/unstable-ext\"],\"unstable-shell-tests\":[\"dep:completest\",\"dep:completest-pty\"]}}",
|
||||
"clap_derive_4.5.49": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.10\"},{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.0\"},{\"name\":\"quote\",\"req\":\"^1.0.9\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.8\"}],\"features\":{\"debug\":[],\"default\":[],\"deprecated\":[],\"raw-deprecated\":[\"deprecated\"],\"unstable-markdown\":[\"dep:pulldown-cmark\",\"dep:anstyle\"],\"unstable-v5\":[\"deprecated\"]}}",
|
||||
"clap_lex_0.7.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"}],\"features\":{}}",
|
||||
@@ -451,6 +451,7 @@
|
||||
"darling_macro_0.20.11": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.20.11\"},{\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{}}",
|
||||
"darling_macro_0.21.3": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.21.3\"},{\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{}}",
|
||||
"darling_macro_0.23.0": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.23.0\"},{\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"name\":\"syn\",\"req\":\"^2.0.15\"}],\"features\":{}}",
|
||||
"data-encoding_2.10.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}",
|
||||
"dbus-secret-service_4.1.0": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"std\"],\"name\":\"block-padding\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"block-padding\",\"alloc\"],\"name\":\"cbc\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"dbus\",\"req\":\"^0.9\"},{\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.3\"},{\"name\":\"hkdf\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"num\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.55\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"features\":[\"derive\"],\"name\":\"zeroize\",\"req\":\"^1.8\"}],\"features\":{\"crypto-openssl\":[\"dep:fastrand\",\"dep:num\",\"dep:once_cell\",\"dep:openssl\"],\"crypto-rust\":[\"dep:aes\",\"dep:block-padding\",\"dep:cbc\",\"dep:fastrand\",\"dep:hkdf\",\"dep:num\",\"dep:once_cell\",\"dep:sha2\"],\"vendored\":[\"dbus/vendored\",\"openssl?/vendored\"]}}",
|
||||
"dbus_0.9.9": "{\"dependencies\":[{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"futures-executor\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"libc\",\"req\":\"^0.2.66\"},{\"name\":\"libdbus-sys\",\"req\":\"^0.2.6\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\"^0.59.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"futures\":[\"futures-util\",\"futures-channel\"],\"no-string-validation\":[],\"stdfd\":[],\"vendored\":[\"libdbus-sys/vendored\"]}}",
|
||||
"deadpool-runtime_0.1.4": "{\"dependencies\":[{\"features\":[\"unstable\"],\"name\":\"async-std_1\",\"optional\":true,\"package\":\"async-std\",\"req\":\"^1.0\"},{\"features\":[\"time\",\"rt\"],\"name\":\"tokio_1\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.0\"}],\"features\":{}}",
|
||||
@@ -495,6 +496,7 @@
|
||||
"enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}",
|
||||
"env-flags_0.1.1": "{\"dependencies\":[],\"features\":{}}",
|
||||
"env_filter_0.1.3": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}",
|
||||
"env_home_0.1.0": "{\"dependencies\":[],\"features\":{}}",
|
||||
"env_logger_0.11.8": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"wincon\"],\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.11\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.6\"},{\"default_features\":false,\"name\":\"env_filter\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2.3\"},{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.21\"}],\"features\":{\"auto-color\":[\"color\",\"anstream/auto\"],\"color\":[\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"auto-color\",\"humantime\",\"regex\"],\"humantime\":[\"dep:jiff\"],\"kv\":[\"log/kv\"],\"regex\":[\"env_filter/regex\"],\"unstable-kv\":[\"kv\"]}}",
|
||||
"equivalent_1.0.2": "{\"dependencies\":[],\"features\":{}}",
|
||||
"erased-serde_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_cbor\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.83\"}],\"features\":{\"alloc\":[\"serde/alloc\"],\"default\":[\"std\"],\"std\":[\"serde/std\"],\"unstable-debug\":[]}}",
|
||||
@@ -905,7 +907,8 @@
|
||||
"tokio-rustls_0.26.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.22\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"]}}",
|
||||
"tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}",
|
||||
"tokio-test_0.4.4": "{\"dependencies\":[{\"name\":\"async-stream\",\"req\":\"^0.3.3\"},{\"name\":\"bytes\",\"req\":\"^1.0.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}",
|
||||
"tokio-util_0.7.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}",
|
||||
"tokio-tungstenite_0.21.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"http1\",\"server\",\"tcp\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14.25\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.25.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.21.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.3.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"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\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[]}}",
|
||||
"tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}",
|
||||
"tokio_1.48.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}",
|
||||
"toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}",
|
||||
"toml_0.9.5": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.5\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.199\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.7\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.116\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.0\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.10\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}",
|
||||
@@ -936,9 +939,10 @@
|
||||
"tree-sitter_0.25.10": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71.1\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.10\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"features\":[\"preserve_order\"],\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1.0.137\"},{\"name\":\"streaming-iterator\",\"req\":\"^0.1.9\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"cranelift\",\"gc-drc\"],\"name\":\"wasmtime-c-api\",\"optional\":true,\"package\":\"wasmtime-c-api-impl\",\"req\":\"^29.0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"regex/std\",\"regex/perf\",\"regex-syntax/unicode\"],\"wasm\":[\"std\",\"wasmtime-c-api\"]}}",
|
||||
"tree_magic_mini_3.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.0\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"once_cell\",\"req\":\"^1.0\"},{\"name\":\"petgraph\",\"req\":\"^0.6.0\"},{\"name\":\"tree_magic_db\",\"optional\":true,\"req\":\"^3.0\"}],\"features\":{\"with-gpl-data\":[\"dep:tree_magic_db\"]}}",
|
||||
"try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}",
|
||||
"ts-rs-macros_11.0.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}",
|
||||
"ts-rs_11.0.1": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"^0.90\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.0.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}",
|
||||
"tui-scrollbar_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6\"},{\"name\":\"crossterm\",\"optional\":true,\"req\":\"^0.29\"},{\"name\":\"document-features\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1\"}],\"features\":{\"crossterm\":[\"dep:crossterm\"]}}",
|
||||
"ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}",
|
||||
"ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}",
|
||||
"tui-scrollbar_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6\"},{\"name\":\"crossterm_0_28\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.28\"},{\"name\":\"crossterm_0_29\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.29\"},{\"name\":\"document-features\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1\"}],\"features\":{\"crossterm\":[\"crossterm_0_29\"],\"crossterm_0_28\":[\"dep:crossterm_0_28\"],\"crossterm_0_29\":[\"dep:crossterm_0_29\"],\"default\":[]}}",
|
||||
"tungstenite_0.21.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.3.2\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\",\"url\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"]}}",
|
||||
"typenum_1.18.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}",
|
||||
"uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}",
|
||||
"uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}",
|
||||
@@ -991,7 +995,7 @@
|
||||
"webpki-root-certs_1.0.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"}],\"features\":{}}",
|
||||
"webpki-roots_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}",
|
||||
"weezl_0.1.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}",
|
||||
"which_6.0.3": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.9.0\"},{\"name\":\"home\",\"req\":\"^0.5.9\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^0.38.30\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}",
|
||||
"which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}",
|
||||
"wildmatch_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ntest\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.2\"},{\"kind\":\"dev\",\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"serde\":[\"dep:serde\"]}}",
|
||||
"winapi-i686-pc-windows-gnu_0.4.0": "{\"dependencies\":[],\"features\":{}}",
|
||||
"winapi-util_0.1.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_Console\",\"Win32_System_SystemInformation\"],\"name\":\"windows-sys\",\"req\":\">=0.48.0, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{}}",
|
||||
|
||||
110
codex-rs/Cargo.lock
generated
110
codex-rs/Cargo.lock
generated
@@ -360,7 +360,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -891,9 +891,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -901,9 +901,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -984,8 +984,10 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"url",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
@@ -1273,6 +1275,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chardetng",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-apply-patch",
|
||||
@@ -1306,6 +1309,7 @@ dependencies = [
|
||||
"image",
|
||||
"include_dir",
|
||||
"indexmap 2.12.0",
|
||||
"indoc",
|
||||
"keyring",
|
||||
"landlock",
|
||||
"libc",
|
||||
@@ -1320,6 +1324,7 @@ dependencies = [
|
||||
"regex",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"schemars 0.8.22",
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1596,7 +1601,9 @@ dependencies = [
|
||||
"bytes",
|
||||
"codex-core",
|
||||
"futures",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -1699,6 +1706,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
@@ -1738,6 +1746,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-cli",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-feedback",
|
||||
@@ -1745,6 +1754,8 @@ dependencies = [
|
||||
"codex-login",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-pty",
|
||||
"codex-windows-sandbox",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
@@ -1810,6 +1821,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-cli",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-feedback",
|
||||
@@ -1818,6 +1830,8 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-tui",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-pty",
|
||||
"codex-windows-sandbox",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
@@ -2126,6 +2140,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"futures",
|
||||
"notify",
|
||||
"pretty_assertions",
|
||||
"regex-lite",
|
||||
@@ -2134,6 +2149,7 @@ dependencies = [
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"walkdir",
|
||||
"wiremock",
|
||||
]
|
||||
@@ -2361,6 +2377,12 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.9"
|
||||
@@ -2763,6 +2785,12 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_home"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.8"
|
||||
@@ -2798,7 +2826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2895,7 +2923,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3836,7 +3864,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5347,7 +5375,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5726,7 +5754,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5739,7 +5767,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7112,10 +7140,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.16"
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -7473,9 +7513,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "11.0.1"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be"
|
||||
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
@@ -7485,9 +7525,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "11.0.1"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a"
|
||||
checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7497,14 +7537,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tui-scrollbar"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42613099915b2e30e9f144670666e858e2538366f77742e1cf1c2f230efcacd"
|
||||
checksum = "0e4267311b5c7999a996ea94939b6d2b1b44a9e5cc11e76cbbb6dcca4c281df4"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.3.1",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
@@ -7989,13 +8048,12 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "6.0.3"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
|
||||
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"rustix 0.38.44",
|
||||
"env_home",
|
||||
"rustix 1.0.8",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
@@ -8027,7 +8085,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -71,6 +71,7 @@ codex-arg0 = { path = "arg0" }
|
||||
codex-async-utils = { path = "async-utils" }
|
||||
codex-backend-client = { path = "backend-client" }
|
||||
codex-chatgpt = { path = "chatgpt" }
|
||||
codex-cli = { path = "cli"}
|
||||
codex-client = { path = "codex-client" }
|
||||
codex-common = { path = "common" }
|
||||
codex-core = { path = "core" }
|
||||
@@ -142,6 +143,7 @@ icu_decimal = "2.1"
|
||||
icu_locale_core = "2.1"
|
||||
icu_provider = { version = "2.1", features = ["sync"] }
|
||||
ignore = "0.4.23"
|
||||
indoc = "2.0"
|
||||
image = { version = "^0.25.9", default-features = false }
|
||||
include_dir = "0.7.4"
|
||||
indexmap = "2.12.0"
|
||||
@@ -192,6 +194,7 @@ serde_yaml = "0.9"
|
||||
serial_test = "3.2.0"
|
||||
sha1 = "0.10.6"
|
||||
sha2 = "0.10"
|
||||
semver = "1.0"
|
||||
shlex = "1.3.0"
|
||||
similar = "2.7.0"
|
||||
socket2 = "0.6.1"
|
||||
@@ -209,7 +212,8 @@ tiny_http = "0.12"
|
||||
tokio = "1"
|
||||
tokio-stream = "0.1.18"
|
||||
tokio-test = "0.4"
|
||||
tokio-util = "0.7.16"
|
||||
tokio-tungstenite = "0.21.0"
|
||||
tokio-util = "0.7.18"
|
||||
toml = "0.9.5"
|
||||
toml_edit = "0.24.0"
|
||||
tracing = "0.1.43"
|
||||
@@ -221,7 +225,7 @@ tree-sitter-bash = "0.25"
|
||||
zstd = "0.13"
|
||||
tree-sitter-highlight = "0.25.10"
|
||||
ts-rs = "11"
|
||||
tui-scrollbar = "0.2.1"
|
||||
tui-scrollbar = "0.2.2"
|
||||
uds_windows = "1.1.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2"
|
||||
@@ -231,7 +235,7 @@ uuid = "1"
|
||||
vt100 = "0.16.2"
|
||||
walkdir = "2.5.0"
|
||||
webbrowser = "1.0"
|
||||
which = "6"
|
||||
which = "8"
|
||||
wildmatch = "2.6.1"
|
||||
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -156,6 +156,11 @@ client_request_definitions! {
|
||||
response: v2::McpServerOauthLoginResponse,
|
||||
},
|
||||
|
||||
McpServerRefresh => "config/mcpServer/reload" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
response: v2::McpServerRefreshResponse,
|
||||
},
|
||||
|
||||
McpServerStatusList => "mcpServerStatus/list" {
|
||||
params: v2::ListMcpServerStatusParams,
|
||||
response: v2::ListMcpServerStatusResponse,
|
||||
@@ -562,6 +567,7 @@ server_notification_definitions! {
|
||||
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
|
||||
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
|
||||
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
|
||||
ConfigWarning => "configWarning" (v2::ConfigWarningNotification),
|
||||
|
||||
/// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.
|
||||
WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification),
|
||||
|
||||
@@ -940,6 +940,16 @@ pub struct ListMcpServerStatusResponse {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerRefreshParams {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerRefreshResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -2097,6 +2107,16 @@ pub struct DeprecationNoticeNotification {
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConfigWarningNotification {
|
||||
/// Concise summary of the warning.
|
||||
pub summary: String,
|
||||
/// Optional extra guidance or error details.
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -88,6 +88,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `model/list` — list available models (with reasoning effort options).
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
|
||||
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
|
||||
@@ -60,6 +60,7 @@ use codex_app_server_protocol::LogoutChatGptResponse;
|
||||
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
|
||||
use codex_app_server_protocol::McpServerOauthLoginParams;
|
||||
use codex_app_server_protocol::McpServerOauthLoginResponse;
|
||||
use codex_app_server_protocol::McpServerRefreshResponse;
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
@@ -157,6 +158,7 @@ use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::GitInfo as CoreGitInfo;
|
||||
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
@@ -425,6 +427,9 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::McpServerOauthLogin { request_id, params } => {
|
||||
self.mcp_server_oauth_login(request_id, params).await;
|
||||
}
|
||||
ClientRequest::McpServerRefresh { request_id, params } => {
|
||||
self.mcp_server_refresh(request_id, params).await;
|
||||
}
|
||||
ClientRequest::McpServerStatusList { request_id, params } => {
|
||||
self.list_mcp_server_status(request_id, params).await;
|
||||
}
|
||||
@@ -2302,6 +2307,57 @@ impl CodexMessageProcessor {
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn mcp_server_refresh(&self, request_id: RequestId, _params: Option<()>) {
|
||||
let config = match self.load_latest_config().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mcp_servers = match serde_json::to_value(config.mcp_servers.get()) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to serialize MCP servers: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mcp_oauth_credentials_store_mode =
|
||||
match serde_json::to_value(config.mcp_oauth_credentials_store_mode) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!(
|
||||
"failed to serialize MCP OAuth credentials store mode: {err}"
|
||||
),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers,
|
||||
mcp_oauth_credentials_store_mode,
|
||||
};
|
||||
|
||||
// Refresh requests are queued per thread; each thread rebuilds MCP connections on its next
|
||||
// active turn to avoid work for threads that never resume.
|
||||
let thread_manager = Arc::clone(&self.thread_manager);
|
||||
thread_manager.refresh_mcp_servers(refresh_config).await;
|
||||
let response = McpServerRefreshResponse {};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn mcp_server_oauth_login(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
@@ -2321,7 +2377,7 @@ impl CodexMessageProcessor {
|
||||
timeout_secs,
|
||||
} = params;
|
||||
|
||||
let Some(server) = config.mcp_servers.get(&name) else {
|
||||
let Some(server) = config.mcp_servers.get().get(&name) else {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("No MCP server named '{name}' found."),
|
||||
@@ -2358,6 +2414,7 @@ impl CodexMessageProcessor {
|
||||
env_http_headers,
|
||||
scopes.as_deref().unwrap_or_default(),
|
||||
timeout_secs,
|
||||
config.mcp_oauth_callback_port,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -135,6 +135,7 @@ mod tests {
|
||||
CoreSandboxModeRequirement::ReadOnly,
|
||||
CoreSandboxModeRequirement::ExternalSandbox,
|
||||
]),
|
||||
mcp_server_requirements: None,
|
||||
};
|
||||
|
||||
let mapped = map_requirements_toml_to_api(requirements);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use std::io::ErrorKind;
|
||||
@@ -10,7 +11,9 @@ use std::path::PathBuf;
|
||||
use crate::message_processor::MessageProcessor;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_core::check_execpolicy_for_warnings;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -44,6 +47,7 @@ pub async fn run_main(
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
cli_config_overrides: CliConfigOverrides,
|
||||
loader_overrides: LoaderOverrides,
|
||||
default_analytics_enabled: bool,
|
||||
) -> IoResult<()> {
|
||||
// Set up channels.
|
||||
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
|
||||
@@ -81,14 +85,38 @@ pub async fn run_main(
|
||||
)
|
||||
})?;
|
||||
let loader_overrides_for_config_api = loader_overrides.clone();
|
||||
let config = ConfigBuilder::default()
|
||||
let mut config_warnings = Vec::new();
|
||||
let config = match ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides.clone())
|
||||
.loader_overrides(loader_overrides)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
|
||||
})?;
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
let message = ConfigWarningNotification {
|
||||
summary: "Invalid configuration; using defaults.".to_string(),
|
||||
details: Some(err.to_string()),
|
||||
};
|
||||
config_warnings.push(message);
|
||||
Config::load_default_with_cli_overrides(cli_kv_overrides.clone()).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("error loading default config after config error: {e}"),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(Some(err)) =
|
||||
check_execpolicy_for_warnings(&config.features, &config.config_layer_stack).await
|
||||
{
|
||||
let message = ConfigWarningNotification {
|
||||
summary: "Error parsing rules; custom rules not applied.".to_string(),
|
||||
details: Some(err.to_string()),
|
||||
};
|
||||
config_warnings.push(message);
|
||||
}
|
||||
|
||||
let feedback = CodexFeedback::new();
|
||||
|
||||
@@ -96,7 +124,7 @@ pub async fn run_main(
|
||||
&config,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
Some("codex_app_server"),
|
||||
false,
|
||||
default_analytics_enabled,
|
||||
)
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(
|
||||
@@ -126,6 +154,12 @@ pub async fn run_main(
|
||||
.with(otel_logger_layer)
|
||||
.with(otel_tracing_layer)
|
||||
.try_init();
|
||||
for warning in &config_warnings {
|
||||
match &warning.details {
|
||||
Some(details) => error!("{} {}", warning.summary, details),
|
||||
None => error!("{}", warning.summary),
|
||||
}
|
||||
}
|
||||
|
||||
// Task: process incoming messages.
|
||||
let processor_handle = tokio::spawn({
|
||||
@@ -139,6 +173,7 @@ pub async fn run_main(
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
feedback.clone(),
|
||||
config_warnings,
|
||||
);
|
||||
async move {
|
||||
while let Some(msg) = incoming_rx.recv().await {
|
||||
|
||||
@@ -20,6 +20,7 @@ fn main() -> anyhow::Result<()> {
|
||||
codex_linux_sandbox_exe,
|
||||
CliConfigOverrides::default(),
|
||||
loader_overrides,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::InitializeResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
@@ -17,6 +18,7 @@ use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
@@ -34,6 +36,7 @@ pub(crate) struct MessageProcessor {
|
||||
codex_message_processor: CodexMessageProcessor,
|
||||
config_api: ConfigApi,
|
||||
initialized: bool,
|
||||
config_warnings: Vec<ConfigWarningNotification>,
|
||||
}
|
||||
|
||||
impl MessageProcessor {
|
||||
@@ -46,6 +49,7 @@ impl MessageProcessor {
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
feedback: CodexFeedback,
|
||||
config_warnings: Vec<ConfigWarningNotification>,
|
||||
) -> Self {
|
||||
let outgoing = Arc::new(outgoing);
|
||||
let auth_manager = AuthManager::shared(
|
||||
@@ -74,6 +78,7 @@ impl MessageProcessor {
|
||||
codex_message_processor,
|
||||
config_api,
|
||||
initialized: false,
|
||||
config_warnings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +160,16 @@ impl MessageProcessor {
|
||||
|
||||
self.initialized = true;
|
||||
|
||||
if !self.config_warnings.is_empty() {
|
||||
for notification in self.config_warnings.drain(..) {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ConfigWarning(
|
||||
notification,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ use codex_app_server_protocol::Model;
|
||||
use codex_app_server_protocol::ReasoningEffortOption;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
|
||||
pub async fn supported_models(thread_manager: Arc<ThreadManager>, config: &Config) -> Vec<Model> {
|
||||
thread_manager
|
||||
.list_models(config)
|
||||
.list_models(config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|preset| preset.show_in_picker)
|
||||
|
||||
@@ -162,6 +162,7 @@ mod tests {
|
||||
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
@@ -279,4 +280,26 @@ mod tests {
|
||||
"ensure the notification serializes correctly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_config_warning_notification_serialization() {
|
||||
let notification = ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||||
summary: "Config error: using defaults".to_string(),
|
||||
details: Some("error loading config: bad config".to_string()),
|
||||
});
|
||||
|
||||
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
|
||||
assert_eq!(
|
||||
json!( {
|
||||
"method": "configWarning",
|
||||
"params": {
|
||||
"summary": "Config error: using defaults",
|
||||
"details": "error loading config: bad config",
|
||||
},
|
||||
}),
|
||||
serde_json::to_value(jsonrpc_notification)
|
||||
.expect("ensure the notification serializes correctly"),
|
||||
"ensure the notification serializes correctly"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,15 @@ use codex_app_server_protocol::SendUserMessageParams;
|
||||
use codex_app_server_protocol::SendUserMessageResponse;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -194,6 +198,9 @@ async fn test_send_message_raw_notifications_opt_in() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let permissions = read_raw_response_item(&mut mcp, conversation_id).await;
|
||||
assert_permissions_message(&permissions);
|
||||
|
||||
let developer = read_raw_response_item(&mut mcp, conversation_id).await;
|
||||
assert_developer_message(&developer, "Use the test harness tools.");
|
||||
|
||||
@@ -340,6 +347,27 @@ fn assert_instructions_message(item: &ResponseItem) {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_permissions_message(item: &ResponseItem) {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
assert_eq!(role, "developer");
|
||||
let texts = content_texts(content);
|
||||
let expected = DeveloperInstructions::from_policy(
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
AskForApproval::Never,
|
||||
&PathBuf::from("/tmp"),
|
||||
)
|
||||
.into_text();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![expected.as_str()],
|
||||
"expected permissions developer message, got {texts:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected permissions message, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_developer_message(item: &ResponseItem, expected_text: &str) {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
|
||||
66
codex-rs/app-server/tests/suite/v2/analytics.rs
Normal file
66
codex-rs/app-server/tests/suite/v2/analytics.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::types::OtelExporterKind;
|
||||
use codex_core::config::types::OtelHttpProtocol;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const SERVICE_VERSION: &str = "0.0.0-test";
|
||||
|
||||
fn set_metrics_exporter(config: &mut codex_core::config::Config) {
|
||||
config.otel.metrics_exporter = OtelExporterKind::OtlpHttp {
|
||||
endpoint: "http://localhost:4318".to_string(),
|
||||
headers: HashMap::new(),
|
||||
protocol: OtelHttpProtocol::Json,
|
||||
tls: None,
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_default_analytics_disabled_without_flag() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
set_metrics_exporter(&mut config);
|
||||
config.analytics_enabled = None;
|
||||
|
||||
let provider = codex_core::otel_init::build_provider(
|
||||
&config,
|
||||
SERVICE_VERSION,
|
||||
Some("codex_app_server"),
|
||||
false,
|
||||
)
|
||||
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
|
||||
|
||||
// With analytics unset in the config and the default flag is false, metrics are disabled.
|
||||
// No provider is built.
|
||||
assert_eq!(provider.is_none(), true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_default_analytics_enabled_with_flag() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
set_metrics_exporter(&mut config);
|
||||
config.analytics_enabled = None;
|
||||
|
||||
let provider = codex_core::otel_init::build_provider(
|
||||
&config,
|
||||
SERVICE_VERSION,
|
||||
Some("codex_app_server"),
|
||||
true,
|
||||
)
|
||||
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
|
||||
|
||||
// With analytics unset in the config and the default flag is true, metrics are enabled.
|
||||
let has_metrics = provider.as_ref().and_then(|otel| otel.metrics()).is_some();
|
||||
assert_eq!(has_metrics, true);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod account;
|
||||
mod analytics;
|
||||
mod config_rpc;
|
||||
mod initialize;
|
||||
mod model_list;
|
||||
|
||||
@@ -383,9 +383,12 @@ fn compute_replacements(
|
||||
let mut line_index: usize = 0;
|
||||
|
||||
for chunk in chunks {
|
||||
// If a chunk has a `change_context`, we use seek_sequence to find it, then
|
||||
// adjust our `line_index` to continue from there.
|
||||
if let Some(ctx_line) = &chunk.change_context {
|
||||
// If a chunk has one or more `change_context` lines, seek them in order
|
||||
// to progressively narrow down the position of the chunk. This supports
|
||||
// multiple @@ context headers such as:
|
||||
// @@ class BaseClass:
|
||||
// @@ def method():
|
||||
for ctx_line in &chunk.change_context {
|
||||
if let Some(idx) = seek_sequence::seek_sequence(
|
||||
original_lines,
|
||||
std::slice::from_ref(ctx_line),
|
||||
@@ -824,6 +827,51 @@ mod tests {
|
||||
assert_eq!(String::from_utf8(stderr).unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_multiple_change_contexts_success() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("example.py");
|
||||
let original = r#"
|
||||
class BaseClass:
|
||||
def method():
|
||||
# untouched
|
||||
pass
|
||||
|
||||
class OtherClass:
|
||||
def method():
|
||||
# to_remove
|
||||
pass
|
||||
"#;
|
||||
fs::write(&path, original).unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@ class OtherClass:
|
||||
@@ def method():
|
||||
- # to_remove
|
||||
+ # to_add"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&path).unwrap();
|
||||
let expected = r#"
|
||||
class BaseClass:
|
||||
def method():
|
||||
# untouched
|
||||
pass
|
||||
|
||||
class OtherClass:
|
||||
def method():
|
||||
# to_add
|
||||
pass
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff() {
|
||||
// Start with a file containing four lines.
|
||||
@@ -1062,4 +1110,40 @@ g
|
||||
let result = apply_patch(&patch, &mut stdout, &mut stderr);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_multiple_change_contexts_missing_context() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("example.py");
|
||||
let original = r#"class BaseClass:
|
||||
def method():
|
||||
# to_remove
|
||||
pass
|
||||
|
||||
class OtherClass:
|
||||
def method():
|
||||
# untouched
|
||||
pass"#;
|
||||
fs::write(&path, original).unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@ class BaseClass:
|
||||
@@ def missing():
|
||||
- # to_remove
|
||||
+ # to_add"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let result = apply_patch(&patch, &mut stdout, &mut stderr);
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ApplyPatchError::IoError(IoError { context, .. }))
|
||||
if context.contains("Failed to find context ' def missing():'")
|
||||
&& context.contains(&path.display().to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +89,13 @@ use Hunk::*;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct UpdateFileChunk {
|
||||
/// A single line of context used to narrow down the position of the chunk
|
||||
/// (this is usually a class, method, or function definition.)
|
||||
pub change_context: Option<String>,
|
||||
/// Lines of context used to narrow down the position of the chunk.
|
||||
/// These are usually class, method, or function definitions. If empty,
|
||||
/// the chunk has no explicit context.
|
||||
pub change_context: Vec<String>,
|
||||
|
||||
/// A contiguous block of lines that should be replaced with `new_lines`.
|
||||
/// `old_lines` must occur strictly after `change_context`.
|
||||
/// `old_lines` must occur strictly after the last `change_context` line.
|
||||
pub old_lines: Vec<String>,
|
||||
pub new_lines: Vec<String>,
|
||||
|
||||
@@ -351,28 +352,42 @@ fn parse_update_file_chunk(
|
||||
line_number,
|
||||
});
|
||||
}
|
||||
// If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
|
||||
// allow treating the chunk as starting directly with diff lines.
|
||||
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
|
||||
(None, 1)
|
||||
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
|
||||
(Some(context.to_string()), 1)
|
||||
} else {
|
||||
if !allow_missing_context {
|
||||
return Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"Expected update hunk to start with a @@ context marker, got: '{}'",
|
||||
lines[0]
|
||||
),
|
||||
line_number,
|
||||
});
|
||||
// Consume one or more explicit context markers (`@@` or `@@ <context>`). This
|
||||
// supports multiple `@@` lines to narrow down the target location, e.g.:
|
||||
// @@ class BaseClass:
|
||||
// @@ def method():
|
||||
// -old
|
||||
// +new
|
||||
let mut change_context: Vec<String> = Vec::new();
|
||||
let mut start_index = 0;
|
||||
while start_index < lines.len() {
|
||||
let line = lines[start_index];
|
||||
if line == EMPTY_CHANGE_CONTEXT_MARKER {
|
||||
start_index += 1;
|
||||
continue;
|
||||
}
|
||||
(None, 0)
|
||||
};
|
||||
if let Some(context) = line.strip_prefix(CHANGE_CONTEXT_MARKER) {
|
||||
change_context.push(context.to_string());
|
||||
start_index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if start_index == 0 && !allow_missing_context {
|
||||
return Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"Expected update hunk to start with a @@ context marker, got: '{}'",
|
||||
lines[0]
|
||||
),
|
||||
line_number,
|
||||
});
|
||||
}
|
||||
|
||||
if start_index >= lines.len() {
|
||||
return Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: line_number + 1,
|
||||
line_number: line_number + start_index,
|
||||
});
|
||||
}
|
||||
let mut chunk = UpdateFileChunk {
|
||||
@@ -517,7 +532,7 @@ fn test_parse_patch() {
|
||||
path: PathBuf::from("path/update.py"),
|
||||
move_path: Some(PathBuf::from("path/update2.py")),
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: Some("def f():".to_string()),
|
||||
change_context: vec!["def f():".to_string()],
|
||||
old_lines: vec![" pass".to_string()],
|
||||
new_lines: vec![" return 123".to_string()],
|
||||
is_end_of_file: false
|
||||
@@ -544,7 +559,7 @@ fn test_parse_patch() {
|
||||
path: PathBuf::from("file.py"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
change_context: Vec::new(),
|
||||
old_lines: vec![],
|
||||
new_lines: vec!["line".to_string()],
|
||||
is_end_of_file: false
|
||||
@@ -574,7 +589,7 @@ fn test_parse_patch() {
|
||||
path: PathBuf::from("file2.py"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
change_context: Vec::new(),
|
||||
old_lines: vec!["import foo".to_string()],
|
||||
new_lines: vec!["import foo".to_string(), "bar".to_string()],
|
||||
is_end_of_file: false,
|
||||
@@ -594,7 +609,7 @@ fn test_parse_patch_lenient() {
|
||||
path: PathBuf::from("file2.py"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
change_context: Vec::new(),
|
||||
old_lines: vec!["import foo".to_string()],
|
||||
new_lines: vec!["import foo".to_string(), "bar".to_string()],
|
||||
is_end_of_file: false,
|
||||
@@ -730,7 +745,7 @@ fn test_update_file_chunk() {
|
||||
),
|
||||
Ok((
|
||||
(UpdateFileChunk {
|
||||
change_context: Some("change_context".to_string()),
|
||||
change_context: vec!["change_context".to_string()],
|
||||
old_lines: vec![
|
||||
"".to_string(),
|
||||
"context".to_string(),
|
||||
@@ -752,7 +767,7 @@ fn test_update_file_chunk() {
|
||||
parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false),
|
||||
Ok((
|
||||
(UpdateFileChunk {
|
||||
change_context: None,
|
||||
change_context: Vec::new(),
|
||||
old_lines: vec![],
|
||||
new_lines: vec!["line".to_string()],
|
||||
is_end_of_file: true
|
||||
@@ -761,3 +776,31 @@ fn test_update_file_chunk() {
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_chunk_multiple_change_context_lines() {
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&[
|
||||
"@@ class BaseClass:",
|
||||
"@@ def method():",
|
||||
"- # to_remove",
|
||||
"+ # to_add",
|
||||
],
|
||||
200,
|
||||
false
|
||||
),
|
||||
Ok((
|
||||
(UpdateFileChunk {
|
||||
change_context: vec![
|
||||
"class BaseClass:".to_string(),
|
||||
" def method():".to_string()
|
||||
],
|
||||
old_lines: vec![" # to_remove".to_string()],
|
||||
new_lines: vec![" # to_add".to_string()],
|
||||
is_end_of_file: false
|
||||
}),
|
||||
4
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/expected/first.py
vendored
Normal file
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/expected/first.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
class BaseClass:
|
||||
def method(self):
|
||||
# to_add
|
||||
pass
|
||||
|
||||
|
||||
class OtherClass:
|
||||
def method(self):
|
||||
# untouched
|
||||
pass
|
||||
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/expected/second.py
vendored
Normal file
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/expected/second.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
class BaseClass:
|
||||
def method(self):
|
||||
# untouched
|
||||
pass
|
||||
|
||||
|
||||
class OtherClass:
|
||||
def method(self):
|
||||
# to_add
|
||||
pass
|
||||
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/input/first.py
vendored
Normal file
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/input/first.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
class BaseClass:
|
||||
def method(self):
|
||||
# to_remove
|
||||
pass
|
||||
|
||||
|
||||
class OtherClass:
|
||||
def method(self):
|
||||
# untouched
|
||||
pass
|
||||
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/input/second.py
vendored
Normal file
10
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/input/second.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
class BaseClass:
|
||||
def method(self):
|
||||
# untouched
|
||||
pass
|
||||
|
||||
|
||||
class OtherClass:
|
||||
def method(self):
|
||||
# to_remove
|
||||
pass
|
||||
12
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/patch.txt
vendored
Normal file
12
codex-rs/apply-patch/tests/fixtures/scenarios/024_multiple_context_lines/patch.txt
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
*** Begin Patch
|
||||
*** Update File: first.py
|
||||
@@ class BaseClass:
|
||||
@@ def method(self):
|
||||
- # to_remove
|
||||
+ # to_add
|
||||
*** Update File: second.py
|
||||
@@ class OtherClass:
|
||||
@@ def method(self):
|
||||
- # to_remove
|
||||
+ # to_add
|
||||
*** End Patch
|
||||
@@ -0,0 +1,5 @@
|
||||
foo
|
||||
bar
|
||||
bar
|
||||
bar
|
||||
new
|
||||
@@ -0,0 +1,5 @@
|
||||
foo
|
||||
bar
|
||||
bar
|
||||
bar
|
||||
baz
|
||||
7
codex-rs/apply-patch/tests/fixtures/scenarios/025_multiple_context_lines_subset/patch.txt
vendored
Normal file
7
codex-rs/apply-patch/tests/fixtures/scenarios/025_multiple_context_lines_subset/patch.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
*** Begin Patch
|
||||
*** Update File: foo.txt
|
||||
@@ bar
|
||||
@@ bar
|
||||
-baz
|
||||
+new
|
||||
*** End Patch
|
||||
@@ -0,0 +1,6 @@
|
||||
foo
|
||||
bar
|
||||
one
|
||||
bar
|
||||
bar
|
||||
two
|
||||
@@ -0,0 +1,6 @@
|
||||
foo
|
||||
bar
|
||||
bar
|
||||
bar
|
||||
bar
|
||||
baz
|
||||
11
codex-rs/apply-patch/tests/fixtures/scenarios/026_multiple_context_lines_overlap/patch.txt
vendored
Normal file
11
codex-rs/apply-patch/tests/fixtures/scenarios/026_multiple_context_lines_overlap/patch.txt
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
*** Begin Patch
|
||||
*** Update File: foo.txt
|
||||
@@ foo
|
||||
@@ bar
|
||||
-bar
|
||||
+one
|
||||
@@ bar
|
||||
@@ bar
|
||||
-baz
|
||||
+two
|
||||
*** End Patch
|
||||
@@ -0,0 +1,4 @@
|
||||
class Foo:
|
||||
def foo(self):
|
||||
# to_remove
|
||||
pass
|
||||
@@ -0,0 +1,10 @@
|
||||
class BaseClass:
|
||||
def method(self):
|
||||
# to_add
|
||||
pass
|
||||
|
||||
|
||||
class OtherClass:
|
||||
def method(self):
|
||||
# untouched
|
||||
pass
|
||||
@@ -0,0 +1,4 @@
|
||||
class Foo:
|
||||
def foo(self):
|
||||
# to_remove
|
||||
pass
|
||||
@@ -0,0 +1,10 @@
|
||||
class BaseClass:
|
||||
def method(self):
|
||||
# to_add
|
||||
pass
|
||||
|
||||
|
||||
class OtherClass:
|
||||
def method(self):
|
||||
# untouched
|
||||
pass
|
||||
@@ -0,0 +1,12 @@
|
||||
*** Begin Patch
|
||||
*** Update File: success.py
|
||||
@@ class BaseClass:
|
||||
@@ def method(self):
|
||||
- # to_remove
|
||||
+ # to_add
|
||||
*** Update File: failure.py
|
||||
@@ class Foo:
|
||||
@@ def missing(self):
|
||||
- # to_remove
|
||||
+ # to_add
|
||||
*** End Patch
|
||||
@@ -0,0 +1,5 @@
|
||||
class Foo:
|
||||
# this is a comment
|
||||
def foo(self):
|
||||
# to_add
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
class Foo:
|
||||
# this is a comment
|
||||
def foo(self):
|
||||
# to_remove
|
||||
pass
|
||||
7
codex-rs/apply-patch/tests/fixtures/scenarios/028_multiple_context_lines_skipped/patch.txt
vendored
Normal file
7
codex-rs/apply-patch/tests/fixtures/scenarios/028_multiple_context_lines_skipped/patch.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
*** Begin Patch
|
||||
*** Update File: skip.py
|
||||
@@ class Foo:
|
||||
@@ def foo(self):
|
||||
- # to_remove
|
||||
+ # to_add
|
||||
*** End Patch
|
||||
@@ -26,6 +26,7 @@ use codex_execpolicy::ExecPolicyCheckCommand;
|
||||
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
|
||||
use codex_tui::AppExitInfo;
|
||||
use codex_tui::Cli as TuiCli;
|
||||
use codex_tui::ExitReason;
|
||||
use codex_tui::update_action::UpdateAction;
|
||||
use codex_tui2 as tui2;
|
||||
use owo_colors::OwoColorize;
|
||||
@@ -119,6 +120,9 @@ enum Subcommand {
|
||||
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
|
||||
Resume(ResumeCommand),
|
||||
|
||||
/// Fork a previous interactive session (picker by default; use --last to fork the most recent).
|
||||
Fork(ForkCommand),
|
||||
|
||||
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
|
||||
#[clap(name = "cloud", alias = "cloud-tasks")]
|
||||
Cloud(CloudTasksCli),
|
||||
@@ -161,6 +165,25 @@ struct ResumeCommand {
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ForkCommand {
|
||||
/// Conversation/session id (UUID). When provided, forks this session.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
session_id: Option<String>,
|
||||
|
||||
/// Fork the most recent session without showing the picker.
|
||||
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
|
||||
last: bool,
|
||||
|
||||
/// Show all sessions (disables cwd filtering and shows CWD column).
|
||||
#[arg(long = "all", default_value_t = false)]
|
||||
all: bool,
|
||||
|
||||
#[clap(flatten)]
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct SandboxArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -246,6 +269,24 @@ struct AppServerCommand {
|
||||
/// Omit to run the app server; specify a subcommand for tooling.
|
||||
#[command(subcommand)]
|
||||
subcommand: Option<AppServerSubcommand>,
|
||||
|
||||
/// Controls whether analytics are enabled by default.
|
||||
///
|
||||
/// Analytics are disabled by default for app-server. Users have to explicitly opt in
|
||||
/// via the `analytics` section in the config.toml file.
|
||||
///
|
||||
/// However, for first-party use cases like the VSCode IDE extension, we default analytics
|
||||
/// to be enabled by default by setting this flag. Users can still opt out by setting this
|
||||
/// in their config.toml:
|
||||
///
|
||||
/// ```toml
|
||||
/// [analytics]
|
||||
/// enabled = false
|
||||
/// ```
|
||||
///
|
||||
/// See https://developers.openai.com/codex/config-advanced/#metrics for more details.
|
||||
#[arg(long = "analytics-default-enabled")]
|
||||
analytics_default_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
@@ -313,6 +354,14 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
|
||||
|
||||
/// Handle the app exit and print the results. Optionally run the update action.
|
||||
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
|
||||
match exit_info.exit_reason {
|
||||
ExitReason::Fatal(message) => {
|
||||
eprintln!("ERROR: {message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
ExitReason::UserRequested => { /* normal exit */ }
|
||||
}
|
||||
|
||||
let update_action = exit_info.update_action;
|
||||
let color_enabled = supports_color::on(Stream::Stdout).is_some();
|
||||
for line in format_exit_messages(exit_info, color_enabled) {
|
||||
@@ -478,6 +527,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
codex_linux_sandbox_exe,
|
||||
root_config_overrides,
|
||||
codex_core::config_loader::LoaderOverrides::default(),
|
||||
app_server_cli.analytics_default_enabled,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -508,6 +558,23 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Fork(ForkCommand {
|
||||
session_id,
|
||||
last,
|
||||
all,
|
||||
config_overrides,
|
||||
})) => {
|
||||
interactive = finalize_fork_interactive(
|
||||
interactive,
|
||||
root_config_overrides.clone(),
|
||||
session_id,
|
||||
last,
|
||||
all,
|
||||
config_overrides,
|
||||
);
|
||||
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Login(mut login_cli)) => {
|
||||
prepend_config_flags(
|
||||
&mut login_cli.config_overrides,
|
||||
@@ -725,7 +792,7 @@ fn finalize_resume_interactive(
|
||||
interactive.resume_show_all = show_all;
|
||||
|
||||
// Merge resume-scoped flags and overrides with highest precedence.
|
||||
merge_resume_cli_flags(&mut interactive, resume_cli);
|
||||
merge_interactive_cli_flags(&mut interactive, resume_cli);
|
||||
|
||||
// Propagate any root-level config overrides (e.g. `-c key=value`).
|
||||
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
|
||||
@@ -733,51 +800,77 @@ fn finalize_resume_interactive(
|
||||
interactive
|
||||
}
|
||||
|
||||
/// Merge flags provided to `codex resume` so they take precedence over any
|
||||
/// root-level flags. Only overrides fields explicitly set on the resume-scoped
|
||||
/// Build the final `TuiCli` for a `codex fork` invocation.
|
||||
fn finalize_fork_interactive(
|
||||
mut interactive: TuiCli,
|
||||
root_config_overrides: CliConfigOverrides,
|
||||
session_id: Option<String>,
|
||||
last: bool,
|
||||
show_all: bool,
|
||||
fork_cli: TuiCli,
|
||||
) -> TuiCli {
|
||||
// Start with the parsed interactive CLI so fork shares the same
|
||||
// configuration surface area as `codex` without additional flags.
|
||||
let fork_session_id = session_id;
|
||||
interactive.fork_picker = fork_session_id.is_none() && !last;
|
||||
interactive.fork_last = last;
|
||||
interactive.fork_session_id = fork_session_id;
|
||||
interactive.fork_show_all = show_all;
|
||||
|
||||
// Merge fork-scoped flags and overrides with highest precedence.
|
||||
merge_interactive_cli_flags(&mut interactive, fork_cli);
|
||||
|
||||
// Propagate any root-level config overrides (e.g. `-c key=value`).
|
||||
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
|
||||
|
||||
interactive
|
||||
}
|
||||
|
||||
/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any
|
||||
/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped
|
||||
/// CLI. Also appends `-c key=value` overrides with highest precedence.
|
||||
fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) {
|
||||
if let Some(model) = resume_cli.model {
|
||||
fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
|
||||
if let Some(model) = subcommand_cli.model {
|
||||
interactive.model = Some(model);
|
||||
}
|
||||
if resume_cli.oss {
|
||||
if subcommand_cli.oss {
|
||||
interactive.oss = true;
|
||||
}
|
||||
if let Some(profile) = resume_cli.config_profile {
|
||||
if let Some(profile) = subcommand_cli.config_profile {
|
||||
interactive.config_profile = Some(profile);
|
||||
}
|
||||
if let Some(sandbox) = resume_cli.sandbox_mode {
|
||||
if let Some(sandbox) = subcommand_cli.sandbox_mode {
|
||||
interactive.sandbox_mode = Some(sandbox);
|
||||
}
|
||||
if let Some(approval) = resume_cli.approval_policy {
|
||||
if let Some(approval) = subcommand_cli.approval_policy {
|
||||
interactive.approval_policy = Some(approval);
|
||||
}
|
||||
if resume_cli.full_auto {
|
||||
if subcommand_cli.full_auto {
|
||||
interactive.full_auto = true;
|
||||
}
|
||||
if resume_cli.dangerously_bypass_approvals_and_sandbox {
|
||||
if subcommand_cli.dangerously_bypass_approvals_and_sandbox {
|
||||
interactive.dangerously_bypass_approvals_and_sandbox = true;
|
||||
}
|
||||
if let Some(cwd) = resume_cli.cwd {
|
||||
if let Some(cwd) = subcommand_cli.cwd {
|
||||
interactive.cwd = Some(cwd);
|
||||
}
|
||||
if resume_cli.web_search {
|
||||
if subcommand_cli.web_search {
|
||||
interactive.web_search = true;
|
||||
}
|
||||
if !resume_cli.images.is_empty() {
|
||||
interactive.images = resume_cli.images;
|
||||
if !subcommand_cli.images.is_empty() {
|
||||
interactive.images = subcommand_cli.images;
|
||||
}
|
||||
if !resume_cli.add_dir.is_empty() {
|
||||
interactive.add_dir.extend(resume_cli.add_dir);
|
||||
if !subcommand_cli.add_dir.is_empty() {
|
||||
interactive.add_dir.extend(subcommand_cli.add_dir);
|
||||
}
|
||||
if let Some(prompt) = resume_cli.prompt {
|
||||
if let Some(prompt) = subcommand_cli.prompt {
|
||||
interactive.prompt = Some(prompt);
|
||||
}
|
||||
|
||||
interactive
|
||||
.config_overrides
|
||||
.raw_overrides
|
||||
.extend(resume_cli.config_overrides.raw_overrides);
|
||||
.extend(subcommand_cli.config_overrides.raw_overrides);
|
||||
}
|
||||
|
||||
fn print_completion(cmd: CompletionCommand) {
|
||||
@@ -794,7 +887,7 @@ mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn finalize_from_args(args: &[&str]) -> TuiCli {
|
||||
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let MultitoolCli {
|
||||
interactive,
|
||||
@@ -823,6 +916,36 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn finalize_fork_from_args(args: &[&str]) -> TuiCli {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let MultitoolCli {
|
||||
interactive,
|
||||
config_overrides: root_overrides,
|
||||
subcommand,
|
||||
feature_toggles: _,
|
||||
} = cli;
|
||||
|
||||
let Subcommand::Fork(ForkCommand {
|
||||
session_id,
|
||||
last,
|
||||
all,
|
||||
config_overrides: fork_cli,
|
||||
}) = subcommand.expect("fork present")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
|
||||
}
|
||||
|
||||
fn app_server_from_args(args: &[&str]) -> AppServerCommand {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else {
|
||||
unreachable!()
|
||||
};
|
||||
app_server
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
|
||||
let token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
@@ -833,6 +956,7 @@ mod tests {
|
||||
token_usage,
|
||||
thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap),
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,6 +966,7 @@ mod tests {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
};
|
||||
let lines = format_exit_messages(exit_info, false);
|
||||
assert!(lines.is_empty());
|
||||
@@ -871,7 +996,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_model_flag_applies_when_no_root_flags() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());
|
||||
let interactive =
|
||||
finalize_resume_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());
|
||||
|
||||
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
|
||||
assert!(interactive.resume_picker);
|
||||
@@ -881,7 +1007,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_picker_logic_none_and_not_last() {
|
||||
let interactive = finalize_from_args(["codex", "resume"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume"].as_ref());
|
||||
assert!(interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
@@ -890,7 +1016,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_picker_logic_last() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "--last"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume", "--last"].as_ref());
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(interactive.resume_last);
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
@@ -899,7 +1025,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_picker_logic_with_session_id() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref());
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
|
||||
@@ -908,14 +1034,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_all_flag_sets_show_all() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "--all"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref());
|
||||
assert!(interactive.resume_picker);
|
||||
assert!(interactive.resume_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_merges_option_flags_and_full_auto() {
|
||||
let interactive = finalize_from_args(
|
||||
let interactive = finalize_resume_from_args(
|
||||
[
|
||||
"codex",
|
||||
"resume",
|
||||
@@ -972,7 +1098,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_merges_dangerously_bypass_flag() {
|
||||
let interactive = finalize_from_args(
|
||||
let interactive = finalize_resume_from_args(
|
||||
[
|
||||
"codex",
|
||||
"resume",
|
||||
@@ -986,6 +1112,53 @@ mod tests {
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_picker_logic_none_and_not_last() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork"].as_ref());
|
||||
assert!(interactive.fork_picker);
|
||||
assert!(!interactive.fork_last);
|
||||
assert_eq!(interactive.fork_session_id, None);
|
||||
assert!(!interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_picker_logic_last() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork", "--last"].as_ref());
|
||||
assert!(!interactive.fork_picker);
|
||||
assert!(interactive.fork_last);
|
||||
assert_eq!(interactive.fork_session_id, None);
|
||||
assert!(!interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_picker_logic_with_session_id() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref());
|
||||
assert!(!interactive.fork_picker);
|
||||
assert!(!interactive.fork_last);
|
||||
assert_eq!(interactive.fork_session_id.as_deref(), Some("1234"));
|
||||
assert!(!interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_all_flag_sets_show_all() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref());
|
||||
assert!(interactive.fork_picker);
|
||||
assert!(interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_analytics_default_disabled_without_flag() {
|
||||
let app_server = app_server_from_args(["codex", "app-server"].as_ref());
|
||||
assert!(!app_server.analytics_default_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_analytics_default_enabled_with_flag() {
|
||||
let app_server =
|
||||
app_server_from_args(["codex", "app-server", "--analytics-default-enabled"].as_ref());
|
||||
assert!(app_server.analytics_default_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_toggles_known_features_generate_overrides() {
|
||||
let toggles = FeatureToggles {
|
||||
|
||||
@@ -274,6 +274,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
|
||||
http_headers.clone(),
|
||||
env_http_headers.clone(),
|
||||
&Vec::new(),
|
||||
config.mcp_oauth_callback_port,
|
||||
)
|
||||
.await?;
|
||||
println!("Successfully logged in.");
|
||||
@@ -331,7 +332,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
|
||||
|
||||
let LoginArgs { name, scopes } = login_args;
|
||||
|
||||
let Some(server) = config.mcp_servers.get(&name) else {
|
||||
let Some(server) = config.mcp_servers.get().get(&name) else {
|
||||
bail!("No MCP server named '{name}' found.");
|
||||
};
|
||||
|
||||
@@ -352,6 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
&scopes,
|
||||
config.mcp_oauth_callback_port,
|
||||
)
|
||||
.await?;
|
||||
println!("Successfully logged in to MCP server '{name}'.");
|
||||
@@ -370,6 +372,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
|
||||
|
||||
let server = config
|
||||
.mcp_servers
|
||||
.get()
|
||||
.get(&name)
|
||||
.ok_or_else(|| anyhow!("No MCP server named '{name}' found in configuration."))?;
|
||||
|
||||
@@ -652,7 +655,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
||||
.await
|
||||
.context("failed to load configuration")?;
|
||||
|
||||
let Some(server) = config.mcp_servers.get(&get_args.name) else {
|
||||
let Some(server) = config.mcp_servers.get().get(&get_args.name) else {
|
||||
bail!("No MCP server named '{name}' found.", name = get_args.name);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ http = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["codec"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
|
||||
@@ -136,6 +136,38 @@ pub struct ResponsesApiRequest<'a> {
|
||||
pub text: Option<TextControls>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseCreateWsRequest {
|
||||
pub model: String,
|
||||
pub instructions: String,
|
||||
pub input: Vec<ResponseItem>,
|
||||
pub tools: Vec<Value>,
|
||||
pub tool_choice: String,
|
||||
pub parallel_tool_calls: bool,
|
||||
pub reasoning: Option<Reasoning>,
|
||||
pub store: bool,
|
||||
pub stream: bool,
|
||||
pub include: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<TextControls>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseAppendWsRequest {
|
||||
pub input: Vec<ResponseItem>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ResponsesWsRequest {
|
||||
#[serde(rename = "response.create")]
|
||||
ResponseCreate(ResponseCreateWsRequest),
|
||||
#[serde(rename = "response.append")]
|
||||
ResponseAppend(ResponseAppendWsRequest),
|
||||
}
|
||||
|
||||
pub fn create_text_param_for_request(
|
||||
verbosity: Option<VerbosityConfig>,
|
||||
output_schema: &Option<Value>,
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod chat;
|
||||
pub mod compact;
|
||||
pub mod models;
|
||||
pub mod responses;
|
||||
pub mod responses_websocket;
|
||||
mod streaming;
|
||||
|
||||
253
codex-rs/codex-api/src/endpoint/responses_websocket.rs
Normal file
253
codex-rs/codex-api/src/endpoint/responses_websocket.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use crate::auth::AuthProvider;
|
||||
use crate::common::ResponseEvent;
|
||||
use crate::common::ResponseStream;
|
||||
use crate::common::ResponsesWsRequest;
|
||||
use crate::error::ApiError;
|
||||
use crate::provider::Provider;
|
||||
use crate::sse::responses::ResponsesStreamEvent;
|
||||
use crate::sse::responses::process_responses_event;
|
||||
use codex_client::TransportError;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use url::Url;
|
||||
|
||||
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
pub struct ResponsesWebsocketConnection {
|
||||
stream: Arc<Mutex<Option<WsStream>>>,
|
||||
// TODO (pakrym): is this the right place for timeout?
|
||||
idle_timeout: Duration,
|
||||
}
|
||||
|
||||
impl ResponsesWebsocketConnection {
|
||||
fn new(stream: WsStream, idle_timeout: Duration) -> Self {
|
||||
Self {
|
||||
stream: Arc::new(Mutex::new(Some(stream))),
|
||||
idle_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_closed(&self) -> bool {
|
||||
self.stream.lock().await.is_none()
|
||||
}
|
||||
|
||||
pub async fn stream_request(
|
||||
&self,
|
||||
request: ResponsesWsRequest,
|
||||
) -> Result<ResponseStream, ApiError> {
|
||||
let (tx_event, rx_event) =
|
||||
mpsc::channel::<std::result::Result<ResponseEvent, ApiError>>(1600);
|
||||
let stream = Arc::clone(&self.stream);
|
||||
let idle_timeout = self.idle_timeout;
|
||||
let request_body = serde_json::to_value(&request).map_err(|err| {
|
||||
ApiError::Stream(format!("failed to encode websocket request: {err}"))
|
||||
})?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut guard = stream.lock().await;
|
||||
let Some(ws_stream) = guard.as_mut() else {
|
||||
let _ = tx_event
|
||||
.send(Err(ApiError::Stream(
|
||||
"websocket connection is closed".to_string(),
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = run_websocket_response_stream(
|
||||
ws_stream,
|
||||
tx_event.clone(),
|
||||
request_body,
|
||||
idle_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = ws_stream.close(None).await;
|
||||
*guard = None;
|
||||
let _ = tx_event.send(Err(err)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResponsesWebsocketClient<A: AuthProvider> {
|
||||
provider: Provider,
|
||||
auth: A,
|
||||
}
|
||||
|
||||
impl<A: AuthProvider> ResponsesWebsocketClient<A> {
|
||||
pub fn new(provider: Provider, auth: A) -> Self {
|
||||
Self { provider, auth }
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
&self,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<ResponsesWebsocketConnection, ApiError> {
|
||||
let ws_url = Url::parse(&self.provider.url_for_path("responses"))
|
||||
.map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?;
|
||||
|
||||
let mut headers = self.provider.headers.clone();
|
||||
headers.extend(extra_headers);
|
||||
apply_auth_headers(&mut headers, &self.auth);
|
||||
|
||||
let stream = connect_websocket(ws_url, headers).await?;
|
||||
Ok(ResponsesWebsocketConnection::new(
|
||||
stream,
|
||||
self.provider.stream_idle_timeout,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO (pakrym): share with /auth
|
||||
fn apply_auth_headers(headers: &mut HeaderMap, auth: &impl AuthProvider) {
|
||||
if let Some(token) = auth.bearer_token()
|
||||
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
{
|
||||
let _ = headers.insert(http::header::AUTHORIZATION, header);
|
||||
}
|
||||
if let Some(account_id) = auth.account_id()
|
||||
&& let Ok(header) = HeaderValue::from_str(&account_id)
|
||||
{
|
||||
let _ = headers.insert("ChatGPT-Account-ID", header);
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_websocket(url: Url, headers: HeaderMap) -> Result<WsStream, ApiError> {
|
||||
let mut request = url
|
||||
.clone()
|
||||
.into_client_request()
|
||||
.map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?;
|
||||
request.headers_mut().extend(headers);
|
||||
|
||||
let (stream, _) = tokio_tungstenite::connect_async(request)
|
||||
.await
|
||||
.map_err(|err| map_ws_error(err, &url))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn map_ws_error(err: WsError, url: &Url) -> ApiError {
|
||||
match err {
|
||||
WsError::Http(response) => {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body = response
|
||||
.body()
|
||||
.as_ref()
|
||||
.and_then(|bytes| String::from_utf8(bytes.clone()).ok());
|
||||
ApiError::Transport(TransportError::Http {
|
||||
status,
|
||||
url: Some(url.to_string()),
|
||||
headers: Some(headers),
|
||||
body,
|
||||
})
|
||||
}
|
||||
WsError::ConnectionClosed | WsError::AlreadyClosed => {
|
||||
ApiError::Stream("websocket closed".to_string())
|
||||
}
|
||||
WsError::Io(err) => ApiError::Transport(TransportError::Network(err.to_string())),
|
||||
other => ApiError::Transport(TransportError::Network(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_websocket_response_stream(
|
||||
ws_stream: &mut WsStream,
|
||||
tx_event: mpsc::Sender<std::result::Result<ResponseEvent, ApiError>>,
|
||||
request_body: Value,
|
||||
idle_timeout: Duration,
|
||||
) -> Result<(), ApiError> {
|
||||
let request_text = match serde_json::to_string(&request_body) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
return Err(ApiError::Stream(format!(
|
||||
"failed to encode websocket request: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ws_stream.send(Message::Text(request_text)).await {
|
||||
return Err(ApiError::Stream(format!(
|
||||
"failed to send websocket request: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
loop {
|
||||
let response = tokio::time::timeout(idle_timeout, ws_stream.next())
|
||||
.await
|
||||
.map_err(|_| ApiError::Stream("idle timeout waiting for websocket".into()));
|
||||
let message = match response {
|
||||
Ok(Some(Ok(msg))) => msg,
|
||||
Ok(Some(Err(err))) => {
|
||||
return Err(ApiError::Stream(err.to_string()));
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(ApiError::Stream(
|
||||
"stream closed before response.completed".into(),
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
match message {
|
||||
Message::Text(text) => {
|
||||
trace!("websocket event: {text}");
|
||||
let event = match serde_json::from_str::<ResponsesStreamEvent>(&text) {
|
||||
Ok(event) => event,
|
||||
Err(err) => {
|
||||
debug!("failed to parse websocket event: {err}, data: {text}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match process_responses_event(event) {
|
||||
Ok(Some(event)) => {
|
||||
let is_completed = matches!(event, ResponseEvent::Completed { .. });
|
||||
let _ = tx_event.send(Ok(event)).await;
|
||||
if is_completed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return Err(error.into_api_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Binary(_) => {
|
||||
return Err(ApiError::Stream("unexpected binary websocket event".into()));
|
||||
}
|
||||
Message::Ping(payload) => {
|
||||
if ws_stream.send(Message::Pong(payload)).await.is_err() {
|
||||
return Err(ApiError::Stream("websocket ping failed".into()));
|
||||
}
|
||||
}
|
||||
Message::Pong(_) => {}
|
||||
Message::Close(_) => {
|
||||
return Err(ApiError::Stream(
|
||||
"websocket closed before response.completed".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod requests;
|
||||
pub mod sse;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use crate::requests::headers::build_conversation_headers;
|
||||
pub use codex_client::RequestTelemetry;
|
||||
pub use codex_client::ReqwestTransport;
|
||||
pub use codex_client::TransportError;
|
||||
@@ -15,6 +16,8 @@ pub use codex_client::TransportError;
|
||||
pub use crate::auth::AuthProvider;
|
||||
pub use crate::common::CompactionInput;
|
||||
pub use crate::common::Prompt;
|
||||
pub use crate::common::ResponseAppendWsRequest;
|
||||
pub use crate::common::ResponseCreateWsRequest;
|
||||
pub use crate::common::ResponseEvent;
|
||||
pub use crate::common::ResponseStream;
|
||||
pub use crate::common::ResponsesApiRequest;
|
||||
@@ -25,6 +28,8 @@ pub use crate::endpoint::compact::CompactClient;
|
||||
pub use crate::endpoint::models::ModelsClient;
|
||||
pub use crate::endpoint::responses::ResponsesClient;
|
||||
pub use crate::endpoint::responses::ResponsesOptions;
|
||||
pub use crate::endpoint::responses_websocket::ResponsesWebsocketClient;
|
||||
pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection;
|
||||
pub use crate::error::ApiError;
|
||||
pub use crate::provider::Provider;
|
||||
pub use crate::provider::WireApi;
|
||||
|
||||
@@ -393,10 +393,6 @@ mod tests {
|
||||
.build(&provider())
|
||||
.expect("request");
|
||||
|
||||
assert_eq!(
|
||||
req.headers.get("conversation_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
);
|
||||
assert_eq!(
|
||||
req.headers.get("session_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
|
||||
@@ -2,10 +2,9 @@ use codex_protocol::protocol::SessionSource;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
|
||||
pub(crate) fn build_conversation_headers(conversation_id: Option<String>) -> HeaderMap {
|
||||
pub fn build_conversation_headers(conversation_id: Option<String>) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(id) = conversation_id {
|
||||
insert_header(&mut headers, "conversation_id", &id);
|
||||
insert_header(&mut headers, "session_id", &id);
|
||||
}
|
||||
headers
|
||||
|
||||
@@ -249,10 +249,6 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(ids, vec![Some("m1".to_string()), None]);
|
||||
|
||||
assert_eq!(
|
||||
request.headers.get("conversation_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("session_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
|
||||
@@ -88,6 +88,14 @@ struct ResponseCompleted {
|
||||
usage: Option<ResponseCompletedUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseDone {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<ResponseCompletedUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseCompletedUsage {
|
||||
input_tokens: i64,
|
||||
@@ -126,7 +134,7 @@ struct ResponseCompletedOutputTokensDetails {
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct SseEvent {
|
||||
pub struct ResponsesStreamEvent {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
response: Option<Value>,
|
||||
@@ -136,6 +144,145 @@ struct SseEvent {
|
||||
content_index: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ResponsesEventError {
|
||||
Api(ApiError),
|
||||
}
|
||||
|
||||
impl ResponsesEventError {
|
||||
pub fn into_api_error(self) -> ApiError {
|
||||
match self {
|
||||
Self::Api(error) => error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_responses_event(
|
||||
event: ResponsesStreamEvent,
|
||||
) -> std::result::Result<Option<ResponseEvent>, ResponsesEventError> {
|
||||
match event.kind.as_str() {
|
||||
"response.output_item.done" => {
|
||||
if let Some(item_val) = event.item {
|
||||
if let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) {
|
||||
return Ok(Some(ResponseEvent::OutputItemDone(item)));
|
||||
}
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
}
|
||||
}
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = event.delta {
|
||||
return Ok(Some(ResponseEvent::OutputTextDelta(delta)));
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_text.delta" => {
|
||||
if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) {
|
||||
return Ok(Some(ResponseEvent::ReasoningSummaryDelta {
|
||||
delta,
|
||||
summary_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"response.reasoning_text.delta" => {
|
||||
if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) {
|
||||
return Ok(Some(ResponseEvent::ReasoningContentDelta {
|
||||
delta,
|
||||
content_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"response.created" => {
|
||||
if event.response.is_some() {
|
||||
return Ok(Some(ResponseEvent::Created {}));
|
||||
}
|
||||
}
|
||||
"response.failed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
let mut response_error = ApiError::Stream("response.failed event received".into());
|
||||
if let Some(error) = resp_val.get("error")
|
||||
&& let Ok(error) = serde_json::from_value::<Error>(error.clone())
|
||||
{
|
||||
if is_context_window_error(&error) {
|
||||
response_error = ApiError::ContextWindowExceeded;
|
||||
} else if is_quota_exceeded_error(&error) {
|
||||
response_error = ApiError::QuotaExceeded;
|
||||
} else if is_usage_not_included(&error) {
|
||||
response_error = ApiError::UsageNotIncluded;
|
||||
} else {
|
||||
let delay = try_parse_retry_after(&error);
|
||||
let message = error.message.unwrap_or_default();
|
||||
response_error = ApiError::Retryable { message, delay };
|
||||
}
|
||||
}
|
||||
return Err(ResponsesEventError::Api(response_error));
|
||||
}
|
||||
|
||||
return Err(ResponsesEventError::Api(ApiError::Stream(
|
||||
"response.failed event received".into(),
|
||||
)));
|
||||
}
|
||||
"response.completed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
match serde_json::from_value::<ResponseCompleted>(resp_val) {
|
||||
Ok(resp) => {
|
||||
return Ok(Some(ResponseEvent::Completed {
|
||||
response_id: resp.id,
|
||||
token_usage: resp.usage.map(Into::into),
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
let error = format!("failed to parse ResponseCompleted: {err}");
|
||||
debug!("{error}");
|
||||
return Err(ResponsesEventError::Api(ApiError::Stream(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.done" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
match serde_json::from_value::<ResponseDone>(resp_val) {
|
||||
Ok(resp) => {
|
||||
return Ok(Some(ResponseEvent::Completed {
|
||||
response_id: resp.id.unwrap_or_default(),
|
||||
token_usage: resp.usage.map(Into::into),
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
let error = format!("failed to parse ResponseCompleted: {err}");
|
||||
debug!("{error}");
|
||||
return Err(ResponsesEventError::Api(ApiError::Stream(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("response.done missing response payload");
|
||||
return Ok(Some(ResponseEvent::Completed {
|
||||
response_id: String::new(),
|
||||
token_usage: None,
|
||||
}));
|
||||
}
|
||||
"response.output_item.added" => {
|
||||
if let Some(item_val) = event.item {
|
||||
if let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) {
|
||||
return Ok(Some(ResponseEvent::OutputItemAdded(item)));
|
||||
}
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_part.added" => {
|
||||
if let Some(summary_index) = event.summary_index {
|
||||
return Ok(Some(ResponseEvent::ReasoningSummaryPartAdded {
|
||||
summary_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
trace!("unhandled responses event: {}", event.kind);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn process_sse(
|
||||
stream: ByteStream,
|
||||
tx_event: mpsc::Sender<Result<ResponseEvent, ApiError>>,
|
||||
@@ -143,7 +290,7 @@ pub async fn process_sse(
|
||||
telemetry: Option<Arc<dyn SseTelemetry>>,
|
||||
) {
|
||||
let mut stream = stream.eventsource();
|
||||
let mut response_completed: Option<ResponseCompleted> = None;
|
||||
let mut response_completed: Option<ResponseEvent> = None;
|
||||
let mut response_error: Option<ApiError> = None;
|
||||
|
||||
loop {
|
||||
@@ -161,11 +308,7 @@ pub async fn process_sse(
|
||||
}
|
||||
Ok(None) => {
|
||||
match response_completed.take() {
|
||||
Some(ResponseCompleted { id, usage }) => {
|
||||
let event = ResponseEvent::Completed {
|
||||
response_id: id,
|
||||
token_usage: usage.map(Into::into),
|
||||
};
|
||||
Some(event) => {
|
||||
let _ = tx_event.send(Ok(event)).await;
|
||||
}
|
||||
None => {
|
||||
@@ -188,7 +331,7 @@ pub async fn process_sse(
|
||||
let raw = sse.data.clone();
|
||||
trace!("SSE event: {raw}");
|
||||
|
||||
let event: SseEvent = match serde_json::from_str(&sse.data) {
|
||||
let event: ResponsesStreamEvent = match serde_json::from_str(&sse.data) {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
debug!("Failed to parse SSE event: {e}, data: {}", &sse.data);
|
||||
@@ -196,115 +339,19 @@ pub async fn process_sse(
|
||||
}
|
||||
};
|
||||
|
||||
match event.kind.as_str() {
|
||||
"response.output_item.done" => {
|
||||
let Some(item_val) = event.item else { continue };
|
||||
let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) else {
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
continue;
|
||||
};
|
||||
|
||||
let event = ResponseEvent::OutputItemDone(item);
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
match process_responses_event(event) {
|
||||
Ok(Some(event)) => {
|
||||
if matches!(event, ResponseEvent::Completed { .. }) {
|
||||
response_completed = Some(event);
|
||||
} else if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = event.delta {
|
||||
let event = ResponseEvent::OutputTextDelta(delta);
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
response_error = Some(error.into_api_error());
|
||||
}
|
||||
"response.reasoning_summary_text.delta" => {
|
||||
if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) {
|
||||
let event = ResponseEvent::ReasoningSummaryDelta {
|
||||
delta,
|
||||
summary_index,
|
||||
};
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.reasoning_text.delta" => {
|
||||
if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) {
|
||||
let event = ResponseEvent::ReasoningContentDelta {
|
||||
delta,
|
||||
content_index,
|
||||
};
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.created" => {
|
||||
if event.response.is_some() {
|
||||
let _ = tx_event.send(Ok(ResponseEvent::Created {})).await;
|
||||
}
|
||||
}
|
||||
"response.failed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
response_error =
|
||||
Some(ApiError::Stream("response.failed event received".into()));
|
||||
|
||||
if let Some(error) = resp_val.get("error")
|
||||
&& let Ok(error) = serde_json::from_value::<Error>(error.clone())
|
||||
{
|
||||
if is_context_window_error(&error) {
|
||||
response_error = Some(ApiError::ContextWindowExceeded);
|
||||
} else if is_quota_exceeded_error(&error) {
|
||||
response_error = Some(ApiError::QuotaExceeded);
|
||||
} else if is_usage_not_included(&error) {
|
||||
response_error = Some(ApiError::UsageNotIncluded);
|
||||
} else {
|
||||
let delay = try_parse_retry_after(&error);
|
||||
let message = error.message.clone().unwrap_or_default();
|
||||
response_error = Some(ApiError::Retryable { message, delay });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.completed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
match serde_json::from_value::<ResponseCompleted>(resp_val) {
|
||||
Ok(r) => {
|
||||
response_completed = Some(r);
|
||||
}
|
||||
Err(e) => {
|
||||
let error = format!("failed to parse ResponseCompleted: {e}");
|
||||
debug!(error);
|
||||
response_error = Some(ApiError::Stream(error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
"response.output_item.added" => {
|
||||
let Some(item_val) = event.item else { continue };
|
||||
let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) else {
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
continue;
|
||||
};
|
||||
|
||||
let event = ResponseEvent::OutputItemAdded(item);
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_part.added" => {
|
||||
if let Some(summary_index) = event.summary_index {
|
||||
let event = ResponseEvent::ReasoningSummaryPartAdded { summary_index };
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
trace!("unhandled SSE event: {:#?}", event.kind);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +548,65 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_done_emits_completed() {
|
||||
let done = json!({
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"input_tokens_details": null,
|
||||
"output_tokens": 2,
|
||||
"output_tokens_details": null,
|
||||
"total_tokens": 3
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse1 = format!("event: response.done\ndata: {done}\n\n");
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
match &events[0] {
|
||||
Ok(ResponseEvent::Completed {
|
||||
response_id,
|
||||
token_usage,
|
||||
}) => {
|
||||
assert_eq!(response_id, "");
|
||||
assert!(token_usage.is_some());
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_done_without_payload_emits_completed() {
|
||||
let done = json!({
|
||||
"type": "response.done"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse1 = format!("event: response.done\ndata: {done}\n\n");
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
match &events[0] {
|
||||
Ok(ResponseEvent::Completed {
|
||||
response_id,
|
||||
token_usage,
|
||||
}) => {
|
||||
assert_eq!(response_id, "");
|
||||
assert!(token_usage.is_none());
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_when_error_event() {
|
||||
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_689bcf18d7f08194bf3440ba62fe05d803fee0cdac429894","object":"response","created_at":1755041560,"status":"failed","background":false,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."}, "usage":null,"user":null,"metadata":{}}}"#;
|
||||
|
||||
@@ -16,7 +16,7 @@ pub use sandbox_mode_cli_arg::SandboxModeCliArg;
|
||||
#[cfg(feature = "cli")]
|
||||
pub mod format_env_display;
|
||||
|
||||
#[cfg(any(feature = "cli", test))]
|
||||
#[cfg(feature = "cli")]
|
||||
mod config_override;
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
|
||||
@@ -1,18 +1,52 @@
|
||||
//! OSS provider utilities shared between TUI and exec.
|
||||
|
||||
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_core::OLLAMA_CHAT_PROVIDER_ID;
|
||||
use codex_core::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::DeprecationNoticeEvent;
|
||||
use std::io;
|
||||
|
||||
/// Returns the default model for a given OSS provider.
|
||||
pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static str> {
|
||||
match provider_id {
|
||||
LMSTUDIO_OSS_PROVIDER_ID => Some(codex_lmstudio::DEFAULT_OSS_MODEL),
|
||||
OLLAMA_OSS_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL),
|
||||
OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a deprecation notice if Ollama doesn't support the responses wire API.
|
||||
pub async fn ollama_chat_deprecation_notice(
|
||||
config: &Config,
|
||||
) -> io::Result<Option<DeprecationNoticeEvent>> {
|
||||
if config.model_provider_id != OLLAMA_OSS_PROVIDER_ID
|
||||
|| config.model_provider.wire_api != WireApi::Responses
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(detection) = codex_ollama::detect_wire_api(&config.model_provider).await?
|
||||
&& detection.wire_api == WireApi::Chat
|
||||
{
|
||||
let version_suffix = detection
|
||||
.version
|
||||
.as_ref()
|
||||
.map(|version| format!(" (version {version})"))
|
||||
.unwrap_or_default();
|
||||
let summary = format!(
|
||||
"Your Ollama server{version_suffix} doesn't support the Responses API. Either update Ollama or set `oss_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"` (or `model_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"`) in your config.toml to use the \"chat\" wire API. Support for the \"chat\" wire API is deprecated and will soon be removed."
|
||||
);
|
||||
return Ok(Some(DeprecationNoticeEvent {
|
||||
summary,
|
||||
details: None,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Ensures the specified OSS provider is ready (models downloaded, service reachable).
|
||||
pub async fn ensure_oss_provider_ready(
|
||||
provider_id: &str,
|
||||
@@ -24,7 +58,7 @@ pub async fn ensure_oss_provider_ready(
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
|
||||
}
|
||||
OLLAMA_OSS_PROVIDER_ID => {
|
||||
OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => {
|
||||
codex_ollama::ensure_oss_ready(config)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
|
||||
|
||||
@@ -20,15 +20,18 @@ codex_rust_crate(
|
||||
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
|
||||
"prompt.md",
|
||||
],
|
||||
# This is a bit of a hack, but empirically, some of our integration tests
|
||||
# are relying on the presence of this file as a repo root marker. When
|
||||
# running tests locally, this "just works," but in remote execution,
|
||||
# the working directory is different and so the file is not found unless it
|
||||
# is explicitly added as test data.
|
||||
#
|
||||
# TODO(aibrahim): Update the tests so that `just bazel-remote-test` succeeds
|
||||
# without this workaround.
|
||||
test_data_extra = ["//:AGENTS.md"],
|
||||
test_data_extra = [
|
||||
"config.schema.json",
|
||||
# This is a bit of a hack, but empirically, some of our integration tests
|
||||
# are relying on the presence of this file as a repo root marker. When
|
||||
# running tests locally, this "just works," but in remote execution,
|
||||
# the working directory is different and so the file is not found unless it
|
||||
# is explicitly added as test data.
|
||||
#
|
||||
# TODO(aibrahim): Update the tests so that `just bazel-remote-test`
|
||||
# succeeds without this workaround.
|
||||
"//:AGENTS.md",
|
||||
],
|
||||
integration_deps_extra = ["//codex-rs/core/tests/common:common"],
|
||||
test_tags = ["no-sandbox"],
|
||||
extra_binaries = [
|
||||
|
||||
@@ -9,17 +9,22 @@ doctest = false
|
||||
name = "codex_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "codex-write-config-schema"
|
||||
path = "src/bin/config_schema.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = "1.7.1"
|
||||
async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
arc-swap = "1.7.1"
|
||||
base64 = { workspace = true }
|
||||
chardetng = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-api = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
@@ -46,6 +51,7 @@ futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
include_dir = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
indoc = { workspace = true }
|
||||
keyring = { workspace = true, features = ["crypto-rust"] }
|
||||
libc = { workspace = true }
|
||||
mcp-types = { workspace = true }
|
||||
@@ -55,6 +61,7 @@ rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
@@ -122,8 +129,12 @@ keyring = { workspace = true, features = ["sync-secret-service"] }
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-core = { path = ".", default-features = false, features = ["deterministic_process_ids"] }
|
||||
codex-otel = { workspace = true, features = ["disable-default-metrics-exporter"] }
|
||||
codex-core = { path = ".", default-features = false, features = [
|
||||
"deterministic_process_ids",
|
||||
] }
|
||||
codex-otel = { workspace = true, features = [
|
||||
"disable-default-metrics-exporter",
|
||||
] }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
|
||||
1450
codex-rs/core/config.schema.json
Normal file
1450
codex-rs/core/config.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,43 +25,6 @@ When using the planning tool:
|
||||
- Do not make single-step plans.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
@@ -25,43 +25,6 @@ When using the planning tool:
|
||||
- Do not make single-step plans.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
@@ -159,43 +159,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for escalating in the tool definition.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify changes once your work is complete.
|
||||
|
||||
@@ -133,43 +133,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for escalating in the tool definition.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests, or the ability to build or run tests, consider using them to verify changes once your work is complete.
|
||||
|
||||
@@ -25,43 +25,6 @@ When using the planning tool:
|
||||
- Do not make single-step plans.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -146,41 +146,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Sandbox and approvals
|
||||
|
||||
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
|
||||
|
||||
Filesystem sandboxing prevents you from editing files without user approval. The options are:
|
||||
|
||||
- **read-only**: You can only read files.
|
||||
- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.
|
||||
- **danger-full-access**: No filesystem sandboxing.
|
||||
|
||||
Network sandboxing prevents you from accessing network without approval. Options are
|
||||
|
||||
- **restricted**
|
||||
- **enabled**
|
||||
|
||||
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
|
||||
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (For all of these, you should weigh alternative paths that do not require approval.)
|
||||
|
||||
Note that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure.
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
|
||||
|
||||
@@ -146,41 +146,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Sandbox and approvals
|
||||
|
||||
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
|
||||
|
||||
Filesystem sandboxing prevents you from editing files without user approval. The options are:
|
||||
|
||||
- **read-only**: You can only read files.
|
||||
- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.
|
||||
- **danger-full-access**: No filesystem sandboxing.
|
||||
|
||||
Network sandboxing prevents you from accessing network without approval. Options are
|
||||
|
||||
- **restricted**
|
||||
- **enabled**
|
||||
|
||||
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
|
||||
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (For all of these, you should weigh alternative paths that do not require approval.)
|
||||
|
||||
Note that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure.
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Control-plane handle for multi-agent operations.
|
||||
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
|
||||
@@ -27,7 +28,6 @@ impl AgentControl {
|
||||
Self { manager }
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
|
||||
/// Spawn a new agent thread and submit the initial prompt.
|
||||
///
|
||||
/// If `headless` is true, a background drain task is spawned to prevent unbounded event growth
|
||||
@@ -50,7 +50,6 @@ impl AgentControl {
|
||||
Ok(new_thread.thread_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
|
||||
/// Send a `user` prompt to an existing agent thread.
|
||||
pub(crate) async fn send_prompt(
|
||||
&self,
|
||||
@@ -58,7 +57,7 @@ impl AgentControl {
|
||||
prompt: String,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
state
|
||||
let result = state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
@@ -66,10 +65,22 @@ impl AgentControl {
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
if matches!(result, Err(CodexErr::InternalAgentDied)) {
|
||||
let _ = state.remove_thread(&agent_id).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
|
||||
/// Submit a shutdown request to an existing agent thread.
|
||||
pub(crate) async fn shutdown_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
let result = state.send_op(agent_id, Op::Shutdown {}).await;
|
||||
let _ = state.remove_thread(&agent_id).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Will be used for collab tools.
|
||||
/// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable.
|
||||
pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus {
|
||||
let Ok(state) = self.upgrade() else {
|
||||
@@ -82,6 +93,16 @@ impl AgentControl {
|
||||
thread.agent_status().await
|
||||
}
|
||||
|
||||
/// Subscribe to status updates for `agent_id`, yielding the latest value and changes.
|
||||
pub(crate) async fn subscribe_status(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
) -> CodexResult<watch::Receiver<AgentStatus>> {
|
||||
let state = self.upgrade()?;
|
||||
let thread = state.get_thread(agent_id).await?;
|
||||
Ok(thread.subscribe_status())
|
||||
}
|
||||
|
||||
fn upgrade(&self) -> CodexResult<Arc<ThreadManagerState>> {
|
||||
self.manager
|
||||
.upgrade()
|
||||
@@ -114,13 +135,63 @@ fn spawn_headless_drain(thread: Arc<CodexThread>) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_config() -> (TempDir, Config) {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
(home, config)
|
||||
}
|
||||
|
||||
struct AgentControlHarness {
|
||||
_home: TempDir,
|
||||
config: Config,
|
||||
manager: ThreadManager,
|
||||
control: AgentControl,
|
||||
}
|
||||
|
||||
impl AgentControlHarness {
|
||||
async fn new() -> Self {
|
||||
let (home, config) = test_config().await;
|
||||
let manager = ThreadManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
Self {
|
||||
_home: home,
|
||||
config,
|
||||
manager,
|
||||
control,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_thread(&self) -> (ThreadId, Arc<CodexThread>) {
|
||||
let new_thread = self
|
||||
.manager
|
||||
.start_thread(self.config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
(new_thread.thread_id, new_thread.thread)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_prompt_errors_when_manager_dropped() {
|
||||
@@ -185,4 +256,135 @@ mod tests {
|
||||
let status = agent_status_from_event(&EventMsg::ShutdownComplete);
|
||||
assert_eq!(status, Some(AgentStatus::Shutdown));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_errors_when_manager_dropped() {
|
||||
let control = AgentControl::default();
|
||||
let (_home, config) = test_config().await;
|
||||
let err = control
|
||||
.spawn_agent(config, "hello".to_string(), false)
|
||||
.await
|
||||
.expect_err("spawn_agent should fail without a manager");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"unsupported operation: thread manager dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_prompt_errors_when_thread_missing() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let err = harness
|
||||
.control
|
||||
.send_prompt(thread_id, "hello".to_string())
|
||||
.await
|
||||
.expect_err("send_prompt should fail for missing thread");
|
||||
assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_status_returns_not_found_for_missing_thread() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let status = harness.control.get_status(ThreadId::new()).await;
|
||||
assert_eq!(status, AgentStatus::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_status_returns_pending_init_for_new_thread() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, _) = harness.start_thread().await;
|
||||
let status = harness.control.get_status(thread_id).await;
|
||||
assert_eq!(status, AgentStatus::PendingInit);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_status_errors_for_missing_thread() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let err = harness
|
||||
.control
|
||||
.subscribe_status(thread_id)
|
||||
.await
|
||||
.expect_err("subscribe_status should fail for missing thread");
|
||||
assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_status_updates_on_shutdown() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, thread) = harness.start_thread().await;
|
||||
let mut status_rx = harness
|
||||
.control
|
||||
.subscribe_status(thread_id)
|
||||
.await
|
||||
.expect("subscribe_status should succeed");
|
||||
assert_eq!(status_rx.borrow().clone(), AgentStatus::PendingInit);
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
|
||||
let _ = status_rx.changed().await;
|
||||
assert_eq!(status_rx.borrow().clone(), AgentStatus::Shutdown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_prompt_submits_user_message() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, _thread) = harness.start_thread().await;
|
||||
|
||||
let submission_id = harness
|
||||
.control
|
||||
.send_prompt(thread_id, "hello from tests".to_string())
|
||||
.await
|
||||
.expect("send_prompt should succeed");
|
||||
assert!(!submission_id.is_empty());
|
||||
let expected = (
|
||||
thread_id,
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello from tests".to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|entry| *entry == expected);
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_creates_thread_and_sends_prompt() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let thread_id = harness
|
||||
.control
|
||||
.spawn_agent(harness.config.clone(), "spawned".to_string(), false)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _thread = harness
|
||||
.manager
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread should be registered");
|
||||
let expected = (
|
||||
thread_id,
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "spawned".to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|entry| *entry == expected);
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,7 @@ pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option<AgentStatus> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_final(status: &AgentStatus) -> bool {
|
||||
!matches!(status, AgentStatus::PendingInit | AgentStatus::Running)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha2::Digest;
|
||||
@@ -21,7 +22,7 @@ use codex_keyring_store::DefaultKeyringStore;
|
||||
use codex_keyring_store::KeyringStore;
|
||||
|
||||
/// Determine where Codex should store CLI auth credentials.
|
||||
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthCredentialsStoreMode {
|
||||
#[default]
|
||||
|
||||
20
codex-rs/core/src/bin/config_schema.rs
Normal file
20
codex-rs/core/src/bin/config_schema.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Generate the JSON Schema for `config.toml` and write it to `config.schema.json`.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "codex-write-config-schema")]
|
||||
struct Args {
|
||||
#[arg(short, long, value_name = "PATH")]
|
||||
out: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let out_path = args
|
||||
.out
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.schema.json"));
|
||||
codex_core::config::schema::write_config_schema(&out_path)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api_bridge::CoreAuthProvider;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::map_api_error;
|
||||
use crate::auth::UnauthorizedRecovery;
|
||||
@@ -10,12 +11,18 @@ use codex_api::CompactionInput as ApiCompactionInput;
|
||||
use codex_api::Prompt as ApiPrompt;
|
||||
use codex_api::RequestTelemetry;
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_api::ResponseAppendWsRequest;
|
||||
use codex_api::ResponseCreateWsRequest;
|
||||
use codex_api::ResponseStream as ApiResponseStream;
|
||||
use codex_api::ResponsesClient as ApiResponsesClient;
|
||||
use codex_api::ResponsesOptions as ApiResponsesOptions;
|
||||
use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient;
|
||||
use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection;
|
||||
use codex_api::SseTelemetry;
|
||||
use codex_api::TransportError;
|
||||
use codex_api::build_conversation_headers;
|
||||
use codex_api::common::Reasoning;
|
||||
use codex_api::common::ResponsesWsRequest;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::requests::responses::Compression;
|
||||
@@ -57,8 +64,8 @@ use crate::model_provider_info::WireApi;
|
||||
use crate::tools::spec::create_tools_json_for_chat_completions_api;
|
||||
use crate::tools::spec::create_tools_json_for_responses_api;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelClient {
|
||||
#[derive(Debug)]
|
||||
struct ModelClientState {
|
||||
config: Arc<Config>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
model_info: ModelInfo,
|
||||
@@ -70,6 +77,17 @@ pub struct ModelClient {
|
||||
session_source: SessionSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelClient {
|
||||
state: Arc<ModelClientState>,
|
||||
}
|
||||
|
||||
pub struct ModelClientSession {
|
||||
state: Arc<ModelClientState>,
|
||||
connection: Option<ApiWebSocketConnection>,
|
||||
websocket_last_items: Vec<ResponseItem>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
impl ModelClient {
|
||||
pub fn new(
|
||||
@@ -84,20 +102,32 @@ impl ModelClient {
|
||||
session_source: SessionSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
auth_manager,
|
||||
model_info,
|
||||
otel_manager,
|
||||
provider,
|
||||
conversation_id,
|
||||
effort,
|
||||
summary,
|
||||
session_source,
|
||||
state: Arc::new(ModelClientState {
|
||||
config,
|
||||
auth_manager,
|
||||
model_info,
|
||||
otel_manager,
|
||||
provider,
|
||||
conversation_id,
|
||||
effort,
|
||||
summary,
|
||||
session_source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_session(&self) -> ModelClientSession {
|
||||
ModelClientSession {
|
||||
state: Arc::clone(&self.state),
|
||||
connection: None,
|
||||
websocket_last_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
pub fn get_model_context_window(&self) -> Option<i64> {
|
||||
let model_info = self.get_model_info();
|
||||
let model_info = &self.state.model_info;
|
||||
let effective_context_window_percent = model_info.effective_context_window_percent;
|
||||
model_info.context_window.map(|context_window| {
|
||||
context_window.saturating_mul(effective_context_window_percent) / 100
|
||||
@@ -105,39 +135,290 @@ impl ModelClient {
|
||||
}
|
||||
|
||||
pub fn config(&self) -> Arc<Config> {
|
||||
Arc::clone(&self.config)
|
||||
Arc::clone(&self.state.config)
|
||||
}
|
||||
|
||||
pub fn provider(&self) -> &ModelProviderInfo {
|
||||
&self.provider
|
||||
&self.state.provider
|
||||
}
|
||||
|
||||
pub fn get_provider(&self) -> ModelProviderInfo {
|
||||
self.state.provider.clone()
|
||||
}
|
||||
|
||||
pub fn get_otel_manager(&self) -> OtelManager {
|
||||
self.state.otel_manager.clone()
|
||||
}
|
||||
|
||||
pub fn get_session_source(&self) -> SessionSource {
|
||||
self.state.session_source.clone()
|
||||
}
|
||||
|
||||
/// Returns the currently configured model slug.
|
||||
pub fn get_model(&self) -> String {
|
||||
self.state.model_info.slug.clone()
|
||||
}
|
||||
|
||||
pub fn get_model_info(&self) -> ModelInfo {
|
||||
self.state.model_info.clone()
|
||||
}
|
||||
|
||||
/// Returns the current reasoning effort setting.
|
||||
pub fn get_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
self.state.effort
|
||||
}
|
||||
|
||||
/// Returns the current reasoning summary setting.
|
||||
pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig {
|
||||
self.state.summary
|
||||
}
|
||||
|
||||
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
|
||||
self.state.auth_manager.clone()
|
||||
}
|
||||
|
||||
/// Compacts the current conversation history using the Compact endpoint.
|
||||
///
|
||||
/// This is a unary call (no streaming) that returns a new list of
|
||||
/// `ResponseItem`s representing the compacted transcript.
|
||||
pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result<Vec<ResponseItem>> {
|
||||
if prompt.input.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let auth = match auth_manager.as_ref() {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_telemetry = self.build_request_telemetry();
|
||||
let client = ApiCompactClient::new(transport, api_provider, api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
|
||||
let instructions = prompt
|
||||
.get_full_instructions(&self.state.model_info)
|
||||
.into_owned();
|
||||
let payload = ApiCompactionInput {
|
||||
model: &self.state.model_info.slug,
|
||||
input: &prompt.input,
|
||||
instructions: &instructions,
|
||||
};
|
||||
|
||||
let mut extra_headers = ApiHeaderMap::new();
|
||||
if let SessionSource::SubAgent(sub) = &self.state.session_source {
|
||||
let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub {
|
||||
label.clone()
|
||||
} else {
|
||||
serde_json::to_value(sub)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
};
|
||||
if let Ok(val) = HeaderValue::from_str(&subagent) {
|
||||
extra_headers.insert("x-openai-subagent", val);
|
||||
}
|
||||
}
|
||||
|
||||
client
|
||||
.compact_input(&payload, extra_headers)
|
||||
.await
|
||||
.map_err(map_api_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClientSession {
|
||||
/// Streams a single model turn using either the Responses or Chat
|
||||
/// Completions wire API, depending on the configured provider.
|
||||
///
|
||||
/// For Chat providers, the underlying stream is optionally aggregated
|
||||
/// based on the `show_raw_agent_reasoning` flag in the config.
|
||||
pub async fn stream(&self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
match self.provider.wire_api {
|
||||
pub async fn stream(&mut self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
match self.state.provider.wire_api {
|
||||
WireApi::Responses => self.stream_responses_api(prompt).await,
|
||||
WireApi::ResponsesWebsocket => self.stream_responses_websocket(prompt).await,
|
||||
WireApi::Chat => {
|
||||
let api_stream = self.stream_chat_completions(prompt).await?;
|
||||
|
||||
if self.config.show_raw_agent_reasoning {
|
||||
if self.state.config.show_raw_agent_reasoning {
|
||||
Ok(map_response_stream(
|
||||
api_stream.streaming_mode(),
|
||||
self.otel_manager.clone(),
|
||||
self.state.otel_manager.clone(),
|
||||
))
|
||||
} else {
|
||||
Ok(map_response_stream(
|
||||
api_stream.aggregate(),
|
||||
self.otel_manager.clone(),
|
||||
self.state.otel_manager.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_responses_request(&self, prompt: &Prompt) -> Result<ApiPrompt> {
|
||||
let model_info = self.state.model_info.clone();
|
||||
let instructions = prompt.get_full_instructions(&model_info).into_owned();
|
||||
let tools_json: Vec<Value> = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
Ok(build_api_prompt(prompt, instructions, tools_json))
|
||||
}
|
||||
|
||||
fn build_responses_options(
|
||||
&self,
|
||||
prompt: &Prompt,
|
||||
compression: Compression,
|
||||
) -> ApiResponsesOptions {
|
||||
let model_info = &self.state.model_info;
|
||||
|
||||
let default_reasoning_effort = model_info.default_reasoning_level;
|
||||
let reasoning = if model_info.supports_reasoning_summaries {
|
||||
Some(Reasoning {
|
||||
effort: self.state.effort.or(default_reasoning_effort),
|
||||
summary: if self.state.summary == ReasoningSummaryConfig::None {
|
||||
None
|
||||
} else {
|
||||
Some(self.state.summary)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let include = if reasoning.is_some() {
|
||||
vec!["reasoning.encrypted_content".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let verbosity = if model_info.support_verbosity {
|
||||
self.state
|
||||
.config
|
||||
.model_verbosity
|
||||
.or(model_info.default_verbosity)
|
||||
} else {
|
||||
if self.state.config.model_verbosity.is_some() {
|
||||
warn!(
|
||||
"model_verbosity is set but ignored as the model does not support verbosity: {}",
|
||||
model_info.slug
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let text = create_text_param_for_request(verbosity, &prompt.output_schema);
|
||||
let conversation_id = self.state.conversation_id.to_string();
|
||||
|
||||
ApiResponsesOptions {
|
||||
reasoning,
|
||||
include,
|
||||
prompt_cache_key: Some(conversation_id.clone()),
|
||||
text,
|
||||
store_override: None,
|
||||
conversation_id: Some(conversation_id),
|
||||
session_source: Some(self.state.session_source.clone()),
|
||||
extra_headers: beta_feature_headers(&self.state.config),
|
||||
compression,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_incremental_items(&self, input_items: &[ResponseItem]) -> Option<Vec<ResponseItem>> {
|
||||
// Checks whether the current request input is an incremental append to the previous request.
|
||||
// If items in the new request contain all the items from the previous request we build
|
||||
// a response.append request otherwise we start with a fresh response.create request.
|
||||
let previous_len = self.websocket_last_items.len();
|
||||
let can_append = previous_len > 0
|
||||
&& input_items.starts_with(&self.websocket_last_items)
|
||||
&& previous_len < input_items.len();
|
||||
if can_append {
|
||||
Some(input_items[previous_len..].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_websocket_request(
|
||||
&self,
|
||||
api_prompt: &ApiPrompt,
|
||||
options: &ApiResponsesOptions,
|
||||
) -> ResponsesWsRequest {
|
||||
if let Some(append_items) = self.get_incremental_items(&api_prompt.input) {
|
||||
return ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest {
|
||||
input: append_items,
|
||||
});
|
||||
}
|
||||
|
||||
let ApiResponsesOptions {
|
||||
reasoning,
|
||||
include,
|
||||
prompt_cache_key,
|
||||
text,
|
||||
store_override,
|
||||
..
|
||||
} = options;
|
||||
|
||||
let store = store_override.unwrap_or(false);
|
||||
let payload = ResponseCreateWsRequest {
|
||||
model: self.state.model_info.slug.clone(),
|
||||
instructions: api_prompt.instructions.clone(),
|
||||
input: api_prompt.input.clone(),
|
||||
tools: api_prompt.tools.clone(),
|
||||
tool_choice: "auto".to_string(),
|
||||
parallel_tool_calls: api_prompt.parallel_tool_calls,
|
||||
reasoning: reasoning.clone(),
|
||||
store,
|
||||
stream: true,
|
||||
include: include.clone(),
|
||||
prompt_cache_key: prompt_cache_key.clone(),
|
||||
text: text.clone(),
|
||||
};
|
||||
|
||||
ResponsesWsRequest::ResponseCreate(payload)
|
||||
}
|
||||
|
||||
async fn websocket_connection(
|
||||
&mut self,
|
||||
api_provider: codex_api::Provider,
|
||||
api_auth: CoreAuthProvider,
|
||||
options: &ApiResponsesOptions,
|
||||
) -> std::result::Result<&ApiWebSocketConnection, ApiError> {
|
||||
let needs_new = match self.connection.as_ref() {
|
||||
Some(conn) => conn.is_closed().await,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if needs_new {
|
||||
let mut headers = options.extra_headers.clone();
|
||||
headers.extend(build_conversation_headers(options.conversation_id.clone()));
|
||||
let new_conn: ApiWebSocketConnection =
|
||||
ApiWebSocketResponsesClient::new(api_provider, api_auth)
|
||||
.connect(headers)
|
||||
.await?;
|
||||
self.connection = Some(new_conn);
|
||||
}
|
||||
|
||||
self.connection.as_ref().ok_or(ApiError::Stream(
|
||||
"websocket connection is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn responses_request_compression(&self, auth: Option<&crate::auth::CodexAuth>) -> Compression {
|
||||
if self
|
||||
.state
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::EnableRequestCompression)
|
||||
&& auth.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
|
||||
&& self.state.provider.is_openai()
|
||||
{
|
||||
Compression::Zstd
|
||||
} else {
|
||||
Compression::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams a turn via the OpenAI Chat Completions API.
|
||||
///
|
||||
/// This path is only used when the provider is configured with
|
||||
@@ -149,13 +430,13 @@ impl ModelClient {
|
||||
));
|
||||
}
|
||||
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let model_info = self.get_model_info();
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let model_info = self.state.model_info.clone();
|
||||
let instructions = prompt.get_full_instructions(&model_info).into_owned();
|
||||
let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?;
|
||||
let api_prompt = build_api_prompt(prompt, instructions, tools_json);
|
||||
let conversation_id = self.conversation_id.to_string();
|
||||
let session_source = self.session_source.clone();
|
||||
let conversation_id = self.state.conversation_id.to_string();
|
||||
let session_source = self.state.session_source.clone();
|
||||
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
@@ -166,9 +447,10 @@ impl ModelClient {
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
|
||||
let client = ApiChatClient::new(transport, api_provider, api_auth)
|
||||
@@ -176,7 +458,7 @@ impl ModelClient {
|
||||
|
||||
let stream_result = client
|
||||
.stream_prompt(
|
||||
&self.get_model(),
|
||||
&self.state.model_info.slug,
|
||||
&api_prompt,
|
||||
Some(conversation_id.clone()),
|
||||
Some(session_source.clone()),
|
||||
@@ -203,52 +485,14 @@ impl ModelClient {
|
||||
async fn stream_responses_api(&self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
if let Some(path) = &*CODEX_RS_SSE_FIXTURE {
|
||||
warn!(path, "Streaming from fixture");
|
||||
let stream = codex_api::stream_from_fixture(path, self.provider.stream_idle_timeout())
|
||||
.map_err(map_api_error)?;
|
||||
return Ok(map_response_stream(stream, self.otel_manager.clone()));
|
||||
let stream =
|
||||
codex_api::stream_from_fixture(path, self.state.provider.stream_idle_timeout())
|
||||
.map_err(map_api_error)?;
|
||||
return Ok(map_response_stream(stream, self.state.otel_manager.clone()));
|
||||
}
|
||||
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let model_info = self.get_model_info();
|
||||
let instructions = prompt.get_full_instructions(&model_info).into_owned();
|
||||
let tools_json: Vec<Value> = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
|
||||
let default_reasoning_effort = model_info.default_reasoning_level;
|
||||
let reasoning = if model_info.supports_reasoning_summaries {
|
||||
Some(Reasoning {
|
||||
effort: self.effort.or(default_reasoning_effort),
|
||||
summary: if self.summary == ReasoningSummaryConfig::None {
|
||||
None
|
||||
} else {
|
||||
Some(self.summary)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let include: Vec<String> = if reasoning.is_some() {
|
||||
vec!["reasoning.encrypted_content".to_string()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let verbosity = if model_info.support_verbosity {
|
||||
self.config.model_verbosity.or(model_info.default_verbosity)
|
||||
} else {
|
||||
if self.config.model_verbosity.is_some() {
|
||||
warn!(
|
||||
"model_verbosity is set but ignored as the model does not support verbosity: {}",
|
||||
model_info.slug
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let text = create_text_param_for_request(verbosity, &prompt.output_schema);
|
||||
let api_prompt = build_api_prompt(prompt, instructions.clone(), tools_json);
|
||||
let conversation_id = self.conversation_id.to_string();
|
||||
let session_source = self.session_source.clone();
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let api_prompt = self.build_responses_request(prompt)?;
|
||||
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
@@ -259,47 +503,26 @@ impl ModelClient {
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
|
||||
let compression = if self
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::EnableRequestCompression)
|
||||
&& auth
|
||||
.as_ref()
|
||||
.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
|
||||
&& self.provider.is_openai()
|
||||
{
|
||||
Compression::Zstd
|
||||
} else {
|
||||
Compression::None
|
||||
};
|
||||
let compression = self.responses_request_compression(auth.as_ref());
|
||||
|
||||
let client = ApiResponsesClient::new(transport, api_provider, api_auth)
|
||||
.with_telemetry(Some(request_telemetry), Some(sse_telemetry));
|
||||
|
||||
let options = ApiResponsesOptions {
|
||||
reasoning: reasoning.clone(),
|
||||
include: include.clone(),
|
||||
prompt_cache_key: Some(conversation_id.clone()),
|
||||
text: text.clone(),
|
||||
store_override: None,
|
||||
conversation_id: Some(conversation_id.clone()),
|
||||
session_source: Some(session_source.clone()),
|
||||
extra_headers: beta_feature_headers(&self.config),
|
||||
compression,
|
||||
};
|
||||
let options = self.build_responses_options(prompt, compression);
|
||||
|
||||
let stream_result = client
|
||||
.stream_prompt(&self.get_model(), &api_prompt, options)
|
||||
.stream_prompt(&self.state.model_info.slug, &api_prompt, options)
|
||||
.await;
|
||||
|
||||
match stream_result {
|
||||
Ok(stream) => {
|
||||
return Ok(map_response_stream(stream, self.otel_manager.clone()));
|
||||
return Ok(map_response_stream(stream, self.state.otel_manager.clone()));
|
||||
}
|
||||
Err(ApiError::Transport(TransportError::Http { status, .. }))
|
||||
if status == StatusCode::UNAUTHORIZED =>
|
||||
@@ -312,106 +535,69 @@ impl ModelClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_provider(&self) -> ModelProviderInfo {
|
||||
self.provider.clone()
|
||||
}
|
||||
/// Streams a turn via the Responses API over WebSocket transport.
|
||||
async fn stream_responses_websocket(&mut self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let api_prompt = self.build_responses_request(prompt)?;
|
||||
|
||||
pub fn get_otel_manager(&self) -> OtelManager {
|
||||
self.otel_manager.clone()
|
||||
}
|
||||
|
||||
pub fn get_session_source(&self) -> SessionSource {
|
||||
self.session_source.clone()
|
||||
}
|
||||
|
||||
/// Returns the currently configured model slug.
|
||||
pub fn get_model(&self) -> String {
|
||||
self.model_info.slug.clone()
|
||||
}
|
||||
|
||||
pub fn get_model_info(&self) -> ModelInfo {
|
||||
self.model_info.clone()
|
||||
}
|
||||
|
||||
/// Returns the current reasoning effort setting.
|
||||
pub fn get_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
self.effort
|
||||
}
|
||||
|
||||
/// Returns the current reasoning summary setting.
|
||||
pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig {
|
||||
self.summary
|
||||
}
|
||||
|
||||
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
|
||||
self.auth_manager.clone()
|
||||
}
|
||||
|
||||
/// Compacts the current conversation history using the Compact endpoint.
|
||||
///
|
||||
/// This is a unary call (no streaming) that returns a new list of
|
||||
/// `ResponseItem`s representing the compacted transcript.
|
||||
pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result<Vec<ResponseItem>> {
|
||||
if prompt.input.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let auth = match auth_manager.as_ref() {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_telemetry = self.build_request_telemetry();
|
||||
let client = ApiCompactClient::new(transport, api_provider, api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
|
||||
let instructions = prompt
|
||||
.get_full_instructions(&self.get_model_info())
|
||||
.into_owned();
|
||||
let payload = ApiCompactionInput {
|
||||
model: &self.get_model(),
|
||||
input: &prompt.input,
|
||||
instructions: &instructions,
|
||||
};
|
||||
|
||||
let mut extra_headers = ApiHeaderMap::new();
|
||||
if let SessionSource::SubAgent(sub) = &self.session_source {
|
||||
let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub {
|
||||
label.clone()
|
||||
} else {
|
||||
serde_json::to_value(sub)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
.map(super::auth::AuthManager::unauthorized_recovery);
|
||||
loop {
|
||||
let auth = match auth_manager.as_ref() {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
if let Ok(val) = HeaderValue::from_str(&subagent) {
|
||||
extra_headers.insert("x-openai-subagent", val);
|
||||
}
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let compression = self.responses_request_compression(auth.as_ref());
|
||||
|
||||
let options = self.build_responses_options(prompt, compression);
|
||||
let request = self.prepare_websocket_request(&api_prompt, &options);
|
||||
|
||||
let connection = match self
|
||||
.websocket_connection(api_provider.clone(), api_auth.clone(), &options)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(ApiError::Transport(TransportError::Http { status, .. }))
|
||||
if status == StatusCode::UNAUTHORIZED =>
|
||||
{
|
||||
handle_unauthorized(status, &mut auth_recovery).await?;
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(map_api_error(err)),
|
||||
};
|
||||
|
||||
let stream_result = connection
|
||||
.stream_request(request)
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
self.websocket_last_items = api_prompt.input.clone();
|
||||
|
||||
return Ok(map_response_stream(
|
||||
stream_result,
|
||||
self.state.otel_manager.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
client
|
||||
.compact_input(&payload, extra_headers)
|
||||
.await
|
||||
.map_err(map_api_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
/// Builds request and SSE telemetry for streaming API calls (Chat/Responses).
|
||||
fn build_streaming_telemetry(&self) -> (Arc<dyn RequestTelemetry>, Arc<dyn SseTelemetry>) {
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.otel_manager.clone()));
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone()));
|
||||
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry.clone();
|
||||
let sse_telemetry: Arc<dyn SseTelemetry> = telemetry;
|
||||
(request_telemetry, sse_telemetry)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
/// Builds request telemetry for unary API calls (e.g., Compact endpoint).
|
||||
fn build_request_telemetry(&self) -> Arc<dyn RequestTelemetry> {
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.otel_manager.clone()));
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone()));
|
||||
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry;
|
||||
request_telemetry
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::prelude::*;
|
||||
use futures::stream::FuturesOrdered;
|
||||
@@ -77,6 +78,7 @@ use tracing::warn;
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::WireApi;
|
||||
use crate::client::ModelClient;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::compact::collect_user_messages;
|
||||
@@ -84,6 +86,7 @@ use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
@@ -107,6 +110,7 @@ use crate::protocol::ErrorEvent;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecApprovalRequestEvent;
|
||||
use crate::protocol::McpServerRefreshConfig;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::ReasoningContentDeltaEvent;
|
||||
@@ -148,7 +152,6 @@ use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::user_instructions::DeveloperInstructions;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::user_notification::UserNotification;
|
||||
use crate::util::backoff;
|
||||
@@ -156,6 +159,7 @@ use codex_async_utils::OrCancelExt;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -164,6 +168,7 @@ use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// The high-level interface to the Codex system.
|
||||
/// It operates as a queue pair where you send submissions and receive events.
|
||||
@@ -172,7 +177,7 @@ pub struct Codex {
|
||||
pub(crate) tx_sub: Sender<Submission>,
|
||||
pub(crate) rx_event: Receiver<Event>,
|
||||
// Last known status of the agent.
|
||||
pub(crate) agent_status: Arc<RwLock<AgentStatus>>,
|
||||
pub(crate) agent_status: watch::Receiver<AgentStatus>,
|
||||
}
|
||||
|
||||
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
|
||||
@@ -246,17 +251,22 @@ impl Codex {
|
||||
|
||||
let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?;
|
||||
.map_err(|err| CodexErr::Fatal(format!("failed to load rules: {err}")))?;
|
||||
|
||||
let config = Arc::new(config);
|
||||
if config.features.enabled(Feature::RemoteModels)
|
||||
&& let Err(err) = models_manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.await
|
||||
{
|
||||
error!("failed to refresh available models: {err:?}");
|
||||
}
|
||||
let model = models_manager.get_model(&config.model, &config).await;
|
||||
let _ = models_manager
|
||||
.list_models(
|
||||
&config,
|
||||
crate::models_manager::manager::RefreshStrategy::OnlineIfUncached,
|
||||
)
|
||||
.await;
|
||||
let model = models_manager
|
||||
.get_default_model(
|
||||
&config.model,
|
||||
&config,
|
||||
crate::models_manager::manager::RefreshStrategy::OnlineIfUncached,
|
||||
)
|
||||
.await;
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
model: model.clone(),
|
||||
@@ -275,7 +285,7 @@ impl Codex {
|
||||
|
||||
// Generate a unique ID for the lifetime of this Codex session.
|
||||
let session_source_clone = session_configuration.session_source.clone();
|
||||
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
|
||||
let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
|
||||
let session = Session::new(
|
||||
session_configuration,
|
||||
@@ -284,7 +294,7 @@ impl Codex {
|
||||
models_manager.clone(),
|
||||
exec_policy,
|
||||
tx_event.clone(),
|
||||
Arc::clone(&agent_status),
|
||||
agent_status_tx.clone(),
|
||||
conversation_history,
|
||||
session_source_clone,
|
||||
skills_manager,
|
||||
@@ -303,7 +313,7 @@ impl Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
rx_event,
|
||||
agent_status,
|
||||
agent_status: agent_status_rx,
|
||||
};
|
||||
|
||||
#[allow(deprecated)]
|
||||
@@ -345,8 +355,7 @@ impl Codex {
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_status(&self) -> AgentStatus {
|
||||
let status = self.agent_status.read().await;
|
||||
status.clone()
|
||||
self.agent_status.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +365,12 @@ impl Codex {
|
||||
pub(crate) struct Session {
|
||||
conversation_id: ThreadId,
|
||||
tx_event: Sender<Event>,
|
||||
agent_status: Arc<RwLock<AgentStatus>>,
|
||||
agent_status: watch::Sender<AgentStatus>,
|
||||
state: Mutex<SessionState>,
|
||||
/// The set of enabled features should be invariant for the lifetime of the
|
||||
/// session.
|
||||
features: Features,
|
||||
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) services: SessionServices,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
@@ -557,7 +567,7 @@ impl Session {
|
||||
models_manager: Arc<ModelsManager>,
|
||||
exec_policy: ExecPolicyManager,
|
||||
tx_event: Sender<Event>,
|
||||
agent_status: Arc<RwLock<AgentStatus>>,
|
||||
agent_status: watch::Sender<AgentStatus>,
|
||||
initial_history: InitialHistory,
|
||||
session_source: SessionSource,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
@@ -685,7 +695,7 @@ impl Session {
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
notifier: UserNotifier::new(config.notify.clone()),
|
||||
rollout: Mutex::new(Some(rollout_recorder)),
|
||||
@@ -703,9 +713,10 @@ impl Session {
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
agent_status: Arc::clone(&agent_status),
|
||||
agent_status,
|
||||
state: Mutex::new(state),
|
||||
features: config.features.clone(),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
@@ -742,16 +753,18 @@ impl Session {
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: session_configuration.cwd.clone(),
|
||||
};
|
||||
let cancel_token = sess.mcp_startup_cancellation_token().await;
|
||||
|
||||
sess.services
|
||||
.mcp_connection_manager
|
||||
.write()
|
||||
.await
|
||||
.initialize(
|
||||
config.mcp_servers.clone(),
|
||||
&config.mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_statuses.clone(),
|
||||
tx_event.clone(),
|
||||
sess.services.mcp_startup_cancellation_token.clone(),
|
||||
cancel_token,
|
||||
sandbox_state,
|
||||
)
|
||||
.await;
|
||||
@@ -852,6 +865,11 @@ impl Session {
|
||||
if persist && !rollout_items.is_empty() {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context);
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
// Flush after seeding history and any persisted rollout copy.
|
||||
self.flush_rollout().await;
|
||||
}
|
||||
@@ -952,7 +970,7 @@ impl Session {
|
||||
let model_info = self
|
||||
.services
|
||||
.models_manager
|
||||
.construct_model_info(session_configuration.model.as_str(), &per_turn_config)
|
||||
.get_model_info(session_configuration.model.as_str(), &per_turn_config)
|
||||
.await;
|
||||
let mut turn_context: TurnContext = Self::make_turn_context(
|
||||
Some(Arc::clone(&self.services.auth_manager)),
|
||||
@@ -975,6 +993,14 @@ impl Session {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_config(&self) -> std::sync::Arc<Config> {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
.session_configuration
|
||||
.original_config_do_not_use
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
|
||||
let session_configuration = {
|
||||
let state = self.state.lock().await;
|
||||
@@ -1004,6 +1030,28 @@ impl Session {
|
||||
)))
|
||||
}
|
||||
|
||||
fn build_permissions_update_item(
|
||||
&self,
|
||||
previous: Option<&Arc<TurnContext>>,
|
||||
next: &TurnContext,
|
||||
) -> Option<ResponseItem> {
|
||||
let prev = previous?;
|
||||
if prev.sandbox_policy == next.sandbox_policy
|
||||
&& prev.approval_policy == next.approval_policy
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
DeveloperInstructions::from_policy(
|
||||
&next.sandbox_policy,
|
||||
next.approval_policy,
|
||||
&next.cwd,
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -1026,8 +1074,7 @@ impl Session {
|
||||
pub(crate) async fn send_event_raw(&self, event: Event) {
|
||||
// Record the last known agent status.
|
||||
if let Some(status) = agent_status_from_event(&event.msg) {
|
||||
let mut guard = self.agent_status.write().await;
|
||||
*guard = status;
|
||||
self.agent_status.send_replace(status);
|
||||
}
|
||||
// Persist the event into rollout (recorder filters as needed)
|
||||
let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())];
|
||||
@@ -1045,8 +1092,7 @@ impl Session {
|
||||
pub(crate) async fn send_event_raw_flushed(&self, event: Event) {
|
||||
// Record the last known agent status.
|
||||
if let Some(status) = agent_status_from_event(&event.msg) {
|
||||
let mut guard = self.agent_status.write().await;
|
||||
*guard = status;
|
||||
self.agent_status.send_replace(status);
|
||||
}
|
||||
self.persist_rollout_items(&[RolloutItem::EventMsg(event.msg.clone())])
|
||||
.await;
|
||||
@@ -1335,8 +1381,16 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
|
||||
let mut items = Vec::<ResponseItem>::with_capacity(3);
|
||||
let mut items = Vec::<ResponseItem>::with_capacity(4);
|
||||
let shell = self.user_shell();
|
||||
items.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
&turn_context.sandbox_policy,
|
||||
turn_context.approval_policy,
|
||||
&turn_context.cwd,
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
|
||||
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
|
||||
}
|
||||
@@ -1351,8 +1405,6 @@ impl Session {
|
||||
}
|
||||
items.push(ResponseItem::from(EnvironmentContext::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
Some(turn_context.approval_policy),
|
||||
Some(turn_context.sandbox_policy.clone()),
|
||||
shell.as_ref().clone(),
|
||||
)));
|
||||
items
|
||||
@@ -1569,6 +1621,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn has_pending_input(&self) -> bool {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
ts.has_pending_input()
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_resources(
|
||||
&self,
|
||||
server: &str,
|
||||
@@ -1649,12 +1712,85 @@ impl Session {
|
||||
Arc::clone(&self.services.user_shell)
|
||||
}
|
||||
|
||||
async fn refresh_mcp_servers_if_requested(&self, turn_context: &TurnContext) {
|
||||
let refresh_config = { self.pending_mcp_server_refresh_config.lock().await.take() };
|
||||
let Some(refresh_config) = refresh_config else {
|
||||
return;
|
||||
};
|
||||
|
||||
let McpServerRefreshConfig {
|
||||
mcp_servers,
|
||||
mcp_oauth_credentials_store_mode,
|
||||
} = refresh_config;
|
||||
|
||||
let mcp_servers =
|
||||
match serde_json::from_value::<HashMap<String, McpServerConfig>>(mcp_servers) {
|
||||
Ok(servers) => servers,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP server refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let store_mode = match serde_json::from_value::<OAuthCredentialsStoreMode>(
|
||||
mcp_oauth_credentials_store_mode,
|
||||
) {
|
||||
Ok(mode) => mode,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP OAuth refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await;
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: turn_context.cwd.clone(),
|
||||
};
|
||||
let cancel_token = self.reset_mcp_startup_cancellation_token().await;
|
||||
|
||||
let mut refreshed_manager = McpConnectionManager::default();
|
||||
refreshed_manager
|
||||
.initialize(
|
||||
&mcp_servers,
|
||||
store_mode,
|
||||
auth_statuses,
|
||||
self.get_tx_event(),
|
||||
cancel_token,
|
||||
sandbox_state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut manager = self.services.mcp_connection_manager.write().await;
|
||||
*manager = refreshed_manager;
|
||||
}
|
||||
|
||||
async fn mcp_startup_cancellation_token(&self) -> CancellationToken {
|
||||
self.services
|
||||
.mcp_startup_cancellation_token
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn reset_mcp_startup_cancellation_token(&self) -> CancellationToken {
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
let cancel_token = CancellationToken::new();
|
||||
*guard = cancel_token.clone();
|
||||
cancel_token
|
||||
}
|
||||
|
||||
fn show_raw_agent_reasoning(&self) -> bool {
|
||||
self.services.show_raw_agent_reasoning
|
||||
}
|
||||
|
||||
async fn cancel_mcp_startup(&self) {
|
||||
self.services.mcp_startup_cancellation_token.cancel();
|
||||
self.services
|
||||
.mcp_startup_cancellation_token
|
||||
.lock()
|
||||
.await
|
||||
.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1712,6 +1848,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::ListMcpTools => {
|
||||
handlers::list_mcp_tools(&sess, &config, sub.id.clone()).await;
|
||||
}
|
||||
Op::RefreshMcpServers { config } => {
|
||||
handlers::refresh_mcp_servers(&sess, config).await;
|
||||
}
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
}
|
||||
@@ -1780,6 +1919,7 @@ mod handlers {
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
@@ -1871,13 +2011,24 @@ mod handlers {
|
||||
|
||||
// Attempt to inject input into current task
|
||||
if let Err(items) = sess.inject_input(items).await {
|
||||
let mut update_items = Vec::new();
|
||||
if let Some(env_item) =
|
||||
sess.build_environment_update_item(previous_context.as_ref(), ¤t_context)
|
||||
{
|
||||
sess.record_conversation_items(¤t_context, std::slice::from_ref(&env_item))
|
||||
update_items.push(env_item);
|
||||
}
|
||||
if let Some(permissions_item) =
|
||||
sess.build_permissions_update_item(previous_context.as_ref(), ¤t_context)
|
||||
{
|
||||
update_items.push(permissions_item);
|
||||
}
|
||||
if !update_items.is_empty() {
|
||||
sess.record_conversation_items(¤t_context, &update_items)
|
||||
.await;
|
||||
}
|
||||
|
||||
sess.refresh_mcp_servers_if_requested(¤t_context)
|
||||
.await;
|
||||
sess.spawn_task(Arc::clone(¤t_context), items, RegularTask)
|
||||
.await;
|
||||
*previous_context = Some(current_context);
|
||||
@@ -2009,6 +2160,11 @@ mod handlers {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn refresh_mcp_servers(sess: &Arc<Session>, refresh_config: McpServerRefreshConfig) {
|
||||
let mut guard = sess.pending_mcp_server_refresh_config.lock().await;
|
||||
*guard = Some(refresh_config);
|
||||
}
|
||||
|
||||
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
|
||||
let snapshot = collect_mcp_snapshot_from_manager(
|
||||
@@ -2193,6 +2349,7 @@ mod handlers {
|
||||
review_request: ReviewRequest,
|
||||
) {
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
|
||||
sess.refresh_mcp_servers_if_requested(&turn_context).await;
|
||||
match resolve_review_request(review_request, turn_context.cwd.as_path()) {
|
||||
Ok(resolved) => {
|
||||
spawn_review_thread(
|
||||
@@ -2230,7 +2387,7 @@ async fn spawn_review_thread(
|
||||
let review_model_info = sess
|
||||
.services
|
||||
.models_manager
|
||||
.construct_model_info(&model, &config)
|
||||
.get_model_info(&model, &config)
|
||||
.await;
|
||||
// For reviews, disable web_search and view_image regardless of global settings.
|
||||
let mut review_features = sess.features.clone();
|
||||
@@ -2399,6 +2556,8 @@ pub(crate) async fn run_turn(
|
||||
// many turns, from the perspective of the user, it is a single turn.
|
||||
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
|
||||
let mut client_session = turn_context.client.new_session();
|
||||
|
||||
loop {
|
||||
// Note that pending_input would be something like a message the user
|
||||
// submitted through the UI while the model was running. Though the UI
|
||||
@@ -2429,6 +2588,7 @@ pub(crate) async fn run_turn(
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
&mut client_session,
|
||||
turn_input,
|
||||
cancellation_token.child_token(),
|
||||
)
|
||||
@@ -2506,6 +2666,7 @@ async fn run_model_turn(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
turn_diff_tracker: SharedTurnDiffTracker,
|
||||
client_session: &mut ModelClientSession,
|
||||
input: Vec<ResponseItem>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> CodexResult<TurnRunResult> {
|
||||
@@ -2546,6 +2707,7 @@ async fn run_model_turn(
|
||||
Arc::clone(&router),
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
client_session,
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
&prompt,
|
||||
cancellation_token.child_token(),
|
||||
@@ -2637,6 +2799,7 @@ async fn try_run_turn(
|
||||
router: Arc<ToolRouter>,
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
client_session: &mut ModelClientSession,
|
||||
turn_diff_tracker: SharedTurnDiffTracker,
|
||||
prompt: &Prompt,
|
||||
cancellation_token: CancellationToken,
|
||||
@@ -2665,9 +2828,7 @@ async fn try_run_turn(
|
||||
);
|
||||
|
||||
sess.persist_rollout_items(&[rollout_item]).await;
|
||||
let mut stream = turn_context
|
||||
.client
|
||||
.clone()
|
||||
let mut stream = client_session
|
||||
.stream(prompt)
|
||||
.instrument(trace_span!("stream_request"))
|
||||
.or_cancel(&cancellation_token)
|
||||
@@ -2756,9 +2917,10 @@ async fn try_run_turn(
|
||||
}
|
||||
ResponseEvent::ModelsEtag(etag) => {
|
||||
// Update internal state with latest models etag
|
||||
let config = sess.get_config().await;
|
||||
sess.services
|
||||
.models_manager
|
||||
.refresh_if_new_etag(etag, sess.features.enabled(Feature::RemoteModels))
|
||||
.refresh_if_new_etag(etag, &config)
|
||||
.await;
|
||||
}
|
||||
ResponseEvent::Completed {
|
||||
@@ -2769,6 +2931,9 @@ async fn try_run_turn(
|
||||
.await;
|
||||
should_emit_turn_diff = true;
|
||||
|
||||
needs_follow_up |= sess.has_pending_input().await;
|
||||
error!("needs_follow_up: {needs_follow_up}");
|
||||
|
||||
break Ok(TurnRunResult {
|
||||
needs_follow_up,
|
||||
last_agent_message,
|
||||
@@ -2945,7 +3110,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_resumed_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
||||
@@ -2955,6 +3120,7 @@ mod tests {
|
||||
}))
|
||||
.await;
|
||||
|
||||
expected.extend(session.build_initial_context(&turn_context));
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -3039,12 +3205,13 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
expected.extend(session.build_initial_context(&turn_context));
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -3494,7 +3661,7 @@ mod tests {
|
||||
));
|
||||
let agent_control = AgentControl::default();
|
||||
let exec_policy = ExecPolicyManager::default();
|
||||
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let model = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
@@ -3528,7 +3695,7 @@ mod tests {
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
notifier: UserNotifier::new(None),
|
||||
rollout: Mutex::new(None),
|
||||
@@ -3557,9 +3724,10 @@ mod tests {
|
||||
let session = Session {
|
||||
conversation_id,
|
||||
tx_event,
|
||||
agent_status: Arc::clone(&agent_status),
|
||||
agent_status: agent_status_tx,
|
||||
state: Mutex::new(state),
|
||||
features: config.features.clone(),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
@@ -3588,7 +3756,7 @@ mod tests {
|
||||
));
|
||||
let agent_control = AgentControl::default();
|
||||
let exec_policy = ExecPolicyManager::default();
|
||||
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let model = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
@@ -3622,7 +3790,7 @@ mod tests {
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
notifier: UserNotifier::new(None),
|
||||
rollout: Mutex::new(None),
|
||||
@@ -3651,9 +3819,10 @@ mod tests {
|
||||
let session = Arc::new(Session {
|
||||
conversation_id,
|
||||
tx_event,
|
||||
agent_status: Arc::clone(&agent_status),
|
||||
agent_status: agent_status_tx,
|
||||
state: Mutex::new(state),
|
||||
features: config.features.clone(),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
@@ -3662,6 +3831,48 @@ mod tests {
|
||||
(session, turn_context, rx_event)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_mcp_servers_is_deferred_until_next_turn() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let old_token = session.mcp_startup_cancellation_token().await;
|
||||
assert!(!old_token.is_cancelled());
|
||||
|
||||
let mcp_oauth_credentials_store_mode =
|
||||
serde_json::to_value(OAuthCredentialsStoreMode::Auto).expect("serialize store mode");
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers: json!({}),
|
||||
mcp_oauth_credentials_store_mode,
|
||||
};
|
||||
{
|
||||
let mut guard = session.pending_mcp_server_refresh_config.lock().await;
|
||||
*guard = Some(refresh_config);
|
||||
}
|
||||
|
||||
assert!(!old_token.is_cancelled());
|
||||
assert!(
|
||||
session
|
||||
.pending_mcp_server_refresh_config
|
||||
.lock()
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
|
||||
session
|
||||
.refresh_mcp_servers_if_requested(&turn_context)
|
||||
.await;
|
||||
|
||||
assert!(old_token.is_cancelled());
|
||||
assert!(
|
||||
session
|
||||
.pending_mcp_server_refresh_config
|
||||
.lock()
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
let new_token = session.mcp_startup_cancellation_token().await;
|
||||
assert!(!new_token.is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_model_warning_appends_user_message() {
|
||||
let (mut session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -87,7 +87,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub: tx_ops,
|
||||
rx_event: rx_sub,
|
||||
agent_status: Arc::clone(&codex.agent_status),
|
||||
agent_status: codex.agent_status.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ pub(crate) async fn run_codex_thread_one_shot(
|
||||
// Bridge events so we can observe completion and shut down automatically.
|
||||
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let ops_tx = io.tx_sub.clone();
|
||||
let agent_status = Arc::clone(&io.agent_status);
|
||||
let agent_status = io.agent_status.clone();
|
||||
let io_for_bridge = io;
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = io_for_bridge.next_event().await {
|
||||
@@ -363,20 +363,23 @@ mod tests {
|
||||
use super::*;
|
||||
use async_channel::bounded;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
|
||||
let (tx_events, rx_events) = bounded(1);
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let codex = Arc::new(Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
rx_event: rx_events,
|
||||
agent_status: Default::default(),
|
||||
agent_status,
|
||||
});
|
||||
|
||||
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::protocol::Event;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::Submission;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub struct CodexThread {
|
||||
codex: Codex,
|
||||
@@ -38,6 +39,10 @@ impl CodexThread {
|
||||
self.codex.agent_status().await
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_status(&self) -> watch::Receiver<AgentStatus> {
|
||||
self.codex.agent_status.clone()
|
||||
}
|
||||
|
||||
pub fn rollout_path(&self) -> PathBuf {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
@@ -1,46 +1,8 @@
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
#[cfg(windows)]
|
||||
#[path = "windows_dangerous_commands.rs"]
|
||||
mod windows_dangerous_commands;
|
||||
|
||||
pub fn requires_initial_appoval(
|
||||
policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> bool {
|
||||
if is_known_safe_command(command) {
|
||||
return false;
|
||||
}
|
||||
match policy {
|
||||
AskForApproval::Never | AskForApproval::OnFailure => false,
|
||||
AskForApproval::OnRequest => {
|
||||
// In DangerFullAccess or ExternalSandbox, only prompt if the command looks dangerous.
|
||||
if matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
|
||||
) {
|
||||
return command_might_be_dangerous(command);
|
||||
}
|
||||
|
||||
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
|
||||
// non‑escalated, non‑dangerous commands — let the sandbox enforce
|
||||
// restrictions (e.g., block network/write) without a user prompt.
|
||||
if sandbox_permissions.requires_escalated_permissions() {
|
||||
return true;
|
||||
}
|
||||
command_might_be_dangerous(command)
|
||||
}
|
||||
AskForApproval::UnlessTrusted => !is_known_safe_command(command),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_might_be_dangerous(command: &[String]) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -86,7 +48,6 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
|
||||
fn vec_str(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(std::string::ToString::to_string).collect()
|
||||
@@ -154,23 +115,4 @@ mod tests {
|
||||
fn rm_f_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_sandbox_only_prompts_for_dangerous_commands() {
|
||||
let external_policy = SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
};
|
||||
assert!(!requires_initial_appoval(
|
||||
AskForApproval::OnRequest,
|
||||
&external_policy,
|
||||
&vec_str(&["ls"]),
|
||||
SandboxPermissions::UseDefault,
|
||||
));
|
||||
assert!(requires_initial_appoval(
|
||||
AskForApproval::OnRequest,
|
||||
&external_policy,
|
||||
&vec_str(&["rm", "-rf", "/"]),
|
||||
SandboxPermissions::UseDefault,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +297,8 @@ async fn drain_to_completed(
|
||||
turn_context: &TurnContext,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<()> {
|
||||
let mut stream = turn_context.client.clone().stream(prompt).await?;
|
||||
let mut client_session = turn_context.client.new_session();
|
||||
let mut stream = client_session.stream(prompt).await?;
|
||||
loop {
|
||||
let maybe_event = stream.next().await;
|
||||
let Some(event) = maybe_event else {
|
||||
|
||||
@@ -37,11 +37,15 @@ impl From<ConstraintError> for std::io::Error {
|
||||
}
|
||||
|
||||
type ConstraintValidator<T> = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync;
|
||||
/// A ConstraintNormalizer is a function which transforms a value into another of the same type.
|
||||
/// `Constrained` uses normalizers to transform values to satisfy constraints or enforce values.
|
||||
type ConstraintNormalizer<T> = dyn Fn(T) -> T + Send + Sync;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Constrained<T> {
|
||||
value: T,
|
||||
validator: Arc<ConstraintValidator<T>>,
|
||||
normalizer: Option<Arc<ConstraintNormalizer<T>>>,
|
||||
}
|
||||
|
||||
impl<T: Send + Sync> Constrained<T> {
|
||||
@@ -54,6 +58,23 @@ impl<T: Send + Sync> Constrained<T> {
|
||||
Ok(Self {
|
||||
value: initial_value,
|
||||
validator,
|
||||
normalizer: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// normalized creates a `Constrained` value with a normalizer function and a validator that allows any value.
|
||||
pub fn normalized(
|
||||
initial_value: T,
|
||||
normalizer: impl Fn(T) -> T + Send + Sync + 'static,
|
||||
) -> ConstraintResult<Self> {
|
||||
let validator: Arc<ConstraintValidator<T>> = Arc::new(|_| Ok(()));
|
||||
let normalizer: Arc<ConstraintNormalizer<T>> = Arc::new(normalizer);
|
||||
let normalized = normalizer(initial_value);
|
||||
validator(&normalized)?;
|
||||
Ok(Self {
|
||||
value: normalized,
|
||||
validator,
|
||||
normalizer: Some(normalizer),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,6 +82,7 @@ impl<T: Send + Sync> Constrained<T> {
|
||||
Self {
|
||||
value: initial_value,
|
||||
validator: Arc::new(|_| Ok(())),
|
||||
normalizer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +110,11 @@ impl<T: Send + Sync> Constrained<T> {
|
||||
}
|
||||
|
||||
pub fn set(&mut self, value: T) -> ConstraintResult<()> {
|
||||
let value = if let Some(normalizer) = &self.normalizer {
|
||||
normalizer(value)
|
||||
} else {
|
||||
value
|
||||
};
|
||||
(self.validator)(&value)?;
|
||||
self.value = value;
|
||||
Ok(())
|
||||
@@ -143,6 +170,17 @@ mod tests {
|
||||
assert_eq!(constrained.value(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_normalizer_applies_on_init_and_set() -> anyhow::Result<()> {
|
||||
let mut constrained = Constrained::normalized(-1, |value| value.max(0))?;
|
||||
assert_eq!(constrained.value(), 0);
|
||||
constrained.set(-5)?;
|
||||
assert_eq!(constrained.value(), 0);
|
||||
constrained.set(10)?;
|
||||
assert_eq!(constrained.value(), 10);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_new_rejects_invalid_initial_value() {
|
||||
let result = Constrained::new(0, |value| {
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::auth::AuthCredentialsStoreMode;
|
||||
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
|
||||
use crate::config::types::History;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config::types::Notice;
|
||||
use crate::config::types::Notifications;
|
||||
use crate::config::types::OtelConfig;
|
||||
@@ -16,6 +17,8 @@ use crate::config::types::UriBasedFileOpener;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::McpServerIdentity;
|
||||
use crate::config_loader::McpServerRequirement;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::features::Feature;
|
||||
use crate::features::FeatureOverrides;
|
||||
@@ -24,6 +27,7 @@ use crate::features::FeaturesToml;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_ID;
|
||||
use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::built_in_model_providers;
|
||||
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
@@ -43,6 +47,7 @@ use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use dirs::home_dir;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use similar::DiffableStr;
|
||||
@@ -61,6 +66,7 @@ use toml_edit::DocumentMut;
|
||||
mod constraint;
|
||||
pub mod edit;
|
||||
pub mod profile;
|
||||
pub mod schema;
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
pub use constraint::Constrained;
|
||||
@@ -257,7 +263,7 @@ pub struct Config {
|
||||
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
|
||||
/// Definition for MCP servers that Codex can reach out to for tool calls.
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
|
||||
|
||||
/// Preferred store for MCP OAuth credentials.
|
||||
/// keyring: Use an OS-specific keyring service.
|
||||
@@ -268,6 +274,11 @@ pub struct Config {
|
||||
/// auto (default): keyring if available, otherwise file.
|
||||
pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
|
||||
|
||||
/// Optional fixed port to use for the local HTTP callback server used during MCP OAuth login.
|
||||
///
|
||||
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
|
||||
pub mcp_oauth_callback_port: Option<u16>,
|
||||
|
||||
/// Combined provider map (defaults merged with user-defined overrides).
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
@@ -449,6 +460,28 @@ impl Config {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Load a default configuration when user config files are invalid.
|
||||
pub fn load_default_with_cli_overrides(
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<Self> {
|
||||
let codex_home = find_codex_home()?;
|
||||
let mut merged = toml::Value::try_from(ConfigToml::default()).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("failed to serialize default config: {e}"),
|
||||
)
|
||||
})?;
|
||||
let cli_layer = crate::config_loader::build_cli_overrides_layer(&cli_overrides);
|
||||
crate::config_loader::merge_toml_values(&mut merged, &cli_layer);
|
||||
let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?;
|
||||
Self::load_config_with_layer_stack(
|
||||
config_toml,
|
||||
ConfigOverrides::default(),
|
||||
codex_home,
|
||||
ConfigLayerStack::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// This is a secondary way of creating [Config], which is appropriate when
|
||||
/// the harness is meant to be used with a specific configuration that
|
||||
/// ignores user settings. For example, the `codex exec` subcommand is
|
||||
@@ -505,6 +538,59 @@ fn deserialize_config_toml_with_base(
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
fn filter_mcp_servers_by_requirements(
|
||||
mcp_servers: &mut HashMap<String, McpServerConfig>,
|
||||
mcp_requirements: Option<&BTreeMap<String, McpServerRequirement>>,
|
||||
) {
|
||||
let Some(allowlist) = mcp_requirements else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (name, server) in mcp_servers.iter_mut() {
|
||||
let allowed = allowlist
|
||||
.get(name)
|
||||
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
|
||||
if !allowed {
|
||||
server.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn constrain_mcp_servers(
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
mcp_requirements: Option<&BTreeMap<String, McpServerRequirement>>,
|
||||
) -> ConstraintResult<Constrained<HashMap<String, McpServerConfig>>> {
|
||||
if mcp_requirements.is_none() {
|
||||
return Ok(Constrained::allow_any(mcp_servers));
|
||||
}
|
||||
|
||||
let mcp_requirements = mcp_requirements.cloned();
|
||||
Constrained::normalized(mcp_servers, move |mut servers| {
|
||||
filter_mcp_servers_by_requirements(&mut servers, mcp_requirements.as_ref());
|
||||
servers
|
||||
})
|
||||
}
|
||||
|
||||
fn mcp_server_matches_requirement(
|
||||
requirement: &McpServerRequirement,
|
||||
server: &McpServerConfig,
|
||||
) -> bool {
|
||||
match &requirement.identity {
|
||||
McpServerIdentity::Command {
|
||||
command: want_command,
|
||||
} => matches!(
|
||||
&server.transport,
|
||||
McpServerTransportConfig::Stdio { command: got_command, .. }
|
||||
if got_command == want_command
|
||||
),
|
||||
McpServerIdentity::Url { url: want_url } => matches!(
|
||||
&server.transport,
|
||||
McpServerTransportConfig::StreamableHttp { url: got_url, .. }
|
||||
if got_url == want_url
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_global_mcp_servers(
|
||||
codex_home: &Path,
|
||||
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
|
||||
@@ -643,14 +729,14 @@ pub fn set_project_trust_level(
|
||||
pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> {
|
||||
// Validate that the provider is one of the known OSS providers
|
||||
match provider {
|
||||
LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => {
|
||||
LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => {
|
||||
// Valid provider, continue
|
||||
}
|
||||
_ => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}"
|
||||
"Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}, {OLLAMA_CHAT_PROVIDER_ID}"
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -682,7 +768,8 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ConfigToml {
|
||||
/// Optional override of model selection.
|
||||
pub model: Option<String>,
|
||||
@@ -741,6 +828,8 @@ pub struct ConfigToml {
|
||||
|
||||
/// Definition for MCP servers that Codex can reach out to for tool calls.
|
||||
#[serde(default)]
|
||||
// Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`.
|
||||
#[schemars(schema_with = "crate::config::schema::mcp_servers_schema")]
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
|
||||
/// Preferred backend for storing MCP OAuth credentials.
|
||||
@@ -751,6 +840,10 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
|
||||
|
||||
/// Optional fixed port for the local HTTP callback server used during MCP OAuth login.
|
||||
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
|
||||
pub mcp_oauth_callback_port: Option<u16>,
|
||||
|
||||
/// User-defined provider entries that extend/override the built-in list.
|
||||
#[serde(default)]
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
@@ -808,6 +901,8 @@ pub struct ConfigToml {
|
||||
|
||||
/// Centralized feature flags (new). Prefer this over individual toggles.
|
||||
#[serde(default)]
|
||||
// Injects known feature keys into the schema and forbids unknown keys.
|
||||
#[schemars(schema_with = "crate::config::schema::features_schema")]
|
||||
pub features: Option<FeaturesToml>,
|
||||
|
||||
/// Settings for ghost snapshots (used for undo).
|
||||
@@ -852,7 +947,7 @@ pub struct ConfigToml {
|
||||
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
/// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
|
||||
/// Preferred OSS provider for local models, e.g. "lmstudio", "ollama", or "ollama-chat".
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
@@ -881,7 +976,8 @@ impl From<ConfigToml> for UserSavedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ProjectConfig {
|
||||
pub trust_level: Option<TrustLevel>,
|
||||
}
|
||||
@@ -896,7 +992,8 @@ impl ProjectConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ToolsToml {
|
||||
#[serde(default, alias = "web_search_request")]
|
||||
pub web_search: Option<bool>,
|
||||
@@ -915,7 +1012,8 @@ impl From<ToolsToml> for Tools {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct GhostSnapshotToml {
|
||||
/// Exclude untracked files larger than this many bytes from ghost snapshots.
|
||||
#[serde(alias = "ignore_untracked_files_over_bytes")]
|
||||
@@ -1327,6 +1425,7 @@ impl Config {
|
||||
let ConfigRequirements {
|
||||
approval_policy: mut constrained_approval_policy,
|
||||
sandbox_policy: mut constrained_sandbox_policy,
|
||||
mcp_server_requirements,
|
||||
} = requirements;
|
||||
|
||||
constrained_approval_policy
|
||||
@@ -1336,6 +1435,12 @@ impl Config {
|
||||
.set(sandbox_policy)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
|
||||
|
||||
let mcp_servers =
|
||||
constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_server_requirements.as_ref())
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}"))
|
||||
})?;
|
||||
|
||||
let config = Self {
|
||||
model,
|
||||
review_model,
|
||||
@@ -1357,10 +1462,11 @@ impl Config {
|
||||
// The config.toml omits "_mode" because it's a config file. However, "_mode"
|
||||
// is important in code to differentiate the mode from the store implementation.
|
||||
cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(),
|
||||
mcp_servers: cfg.mcp_servers,
|
||||
mcp_servers,
|
||||
// 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.
|
||||
mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(),
|
||||
mcp_oauth_callback_port: cfg.mcp_oauth_callback_port,
|
||||
model_providers,
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
|
||||
project_doc_fallback_filenames: cfg
|
||||
@@ -1595,9 +1701,44 @@ mod tests {
|
||||
use core_test_support::test_absolute_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn stdio_mcp(command: &str) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: command.to_string(),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_mcp(url: &str) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: url.to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_parsing() {
|
||||
let history_with_persistence = r#"
|
||||
@@ -1802,6 +1943,122 @@ trust_level = "trusted"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
|
||||
const MISMATCHED_COMMAND_SERVER: &str = "mismatched-command-should-disable";
|
||||
const MISMATCHED_URL_SERVER: &str = "mismatched-url-should-disable";
|
||||
const MATCHED_COMMAND_SERVER: &str = "matched-command-should-allow";
|
||||
const MATCHED_URL_SERVER: &str = "matched-url-should-allow";
|
||||
const DIFFERENT_NAME_SERVER: &str = "different-name-should-disable";
|
||||
|
||||
const GOOD_CMD: &str = "good-cmd";
|
||||
const GOOD_URL: &str = "https://example.com/good";
|
||||
|
||||
let mut servers = HashMap::from([
|
||||
(MISMATCHED_COMMAND_SERVER.to_string(), stdio_mcp("docs-cmd")),
|
||||
(
|
||||
MISMATCHED_URL_SERVER.to_string(),
|
||||
http_mcp("https://example.com/mcp"),
|
||||
),
|
||||
(MATCHED_COMMAND_SERVER.to_string(), stdio_mcp(GOOD_CMD)),
|
||||
(MATCHED_URL_SERVER.to_string(), http_mcp(GOOD_URL)),
|
||||
(DIFFERENT_NAME_SERVER.to_string(), stdio_mcp("same-cmd")),
|
||||
]);
|
||||
filter_mcp_servers_by_requirements(
|
||||
&mut servers,
|
||||
Some(&BTreeMap::from([
|
||||
(
|
||||
MISMATCHED_URL_SERVER.to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://example.com/other".to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
MISMATCHED_COMMAND_SERVER.to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "other-cmd".to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
MATCHED_URL_SERVER.to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: GOOD_URL.to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
MATCHED_COMMAND_SERVER.to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: GOOD_CMD.to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
])),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (name.clone(), server.enabled))
|
||||
.collect::<HashMap<String, bool>>(),
|
||||
HashMap::from([
|
||||
(MISMATCHED_URL_SERVER.to_string(), false),
|
||||
(MISMATCHED_COMMAND_SERVER.to_string(), false),
|
||||
(MATCHED_URL_SERVER.to_string(), true),
|
||||
(MATCHED_COMMAND_SERVER.to_string(), true),
|
||||
(DIFFERENT_NAME_SERVER.to_string(), false),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mcp_servers_by_allowlist_allows_all_when_unset() {
|
||||
let mut servers = HashMap::from([
|
||||
("server-a".to_string(), stdio_mcp("cmd-a")),
|
||||
("server-b".to_string(), http_mcp("https://example.com/b")),
|
||||
]);
|
||||
|
||||
filter_mcp_servers_by_requirements(&mut servers, None);
|
||||
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (name.clone(), server.enabled))
|
||||
.collect::<HashMap<String, bool>>(),
|
||||
HashMap::from([
|
||||
("server-a".to_string(), true),
|
||||
("server-b".to_string(), true),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() {
|
||||
let mut servers = HashMap::from([
|
||||
("server-a".to_string(), stdio_mcp("cmd-a")),
|
||||
("server-b".to_string(), http_mcp("https://example.com/b")),
|
||||
]);
|
||||
|
||||
filter_mcp_servers_by_requirements(&mut servers, Some(&BTreeMap::new()));
|
||||
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (name.clone(), server.enabled))
|
||||
.collect::<HashMap<String, bool>>(),
|
||||
HashMap::from([
|
||||
("server-a".to_string(), false),
|
||||
("server-b".to_string(), false),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
@@ -3243,8 +3500,9 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3329,8 +3587,9 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3430,8 +3689,9 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3517,8 +3777,9 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3832,6 +4093,34 @@ trust_level = "untrusted"
|
||||
assert_eq!(result, Some("explicit-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_mcp_oauth_callback_port() {
|
||||
let toml = r#"mcp_oauth_callback_port = 4321"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
|
||||
assert_eq!(cfg.mcp_oauth_callback_port, Some(4321));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let toml = r#"
|
||||
model = "gpt-5.1"
|
||||
mcp_oauth_callback_port = 5678
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(config.mcp_oauth_callback_port, Some(5678));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -10,7 +11,8 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
/// Collection of common configuration options that a user can define as a unit
|
||||
/// in `config.toml`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ConfigProfile {
|
||||
pub model: Option<String>,
|
||||
/// The key in the `model_providers` map identifying the
|
||||
@@ -32,6 +34,8 @@ pub struct ConfigProfile {
|
||||
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
|
||||
/// Optional feature toggles scoped to this profile.
|
||||
#[serde(default)]
|
||||
// Injects known feature keys into the schema and forbids unknown keys.
|
||||
#[schemars(schema_with = "crate::config::schema::features_schema")]
|
||||
pub features: Option<crate::features::FeaturesToml>,
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
11
codex-rs/core/src/config/schema.md
Normal file
11
codex-rs/core/src/config/schema.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Config JSON Schema
|
||||
|
||||
We generate a JSON Schema for `~/.codex/config.toml` from the `ConfigToml` type
|
||||
and commit it at `codex-rs/core/config.schema.json` for editor integration.
|
||||
|
||||
When you change any fields included in `ConfigToml` (or nested config types),
|
||||
regenerate the schema:
|
||||
|
||||
```
|
||||
just write-config-schema
|
||||
```
|
||||
127
codex-rs/core/src/config/schema.rs
Normal file
127
codex-rs/core/src/config/schema.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::types::RawMcpServerConfig;
|
||||
use crate::features::FEATURES;
|
||||
use schemars::r#gen::SchemaGenerator;
|
||||
use schemars::r#gen::SchemaSettings;
|
||||
use schemars::schema::InstanceType;
|
||||
use schemars::schema::ObjectValidation;
|
||||
use schemars::schema::RootSchema;
|
||||
use schemars::schema::Schema;
|
||||
use schemars::schema::SchemaObject;
|
||||
use std::path::Path;
|
||||
|
||||
/// Schema for the `[features]` map with known + legacy keys only.
|
||||
pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
let mut object = SchemaObject {
|
||||
instance_type: Some(InstanceType::Object.into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut validation = ObjectValidation::default();
|
||||
for feature in FEATURES {
|
||||
validation
|
||||
.properties
|
||||
.insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());
|
||||
}
|
||||
for legacy_key in crate::features::legacy_feature_keys() {
|
||||
validation
|
||||
.properties
|
||||
.insert(legacy_key.to_string(), schema_gen.subschema_for::<bool>());
|
||||
}
|
||||
validation.additional_properties = Some(Box::new(Schema::Bool(false)));
|
||||
object.object = Some(Box::new(validation));
|
||||
|
||||
Schema::Object(object)
|
||||
}
|
||||
|
||||
/// Schema for the `[mcp_servers]` map using the raw input shape.
|
||||
pub(crate) fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
let mut object = SchemaObject {
|
||||
instance_type: Some(InstanceType::Object.into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let validation = ObjectValidation {
|
||||
additional_properties: Some(Box::new(schema_gen.subschema_for::<RawMcpServerConfig>())),
|
||||
..Default::default()
|
||||
};
|
||||
object.object = Some(Box::new(validation));
|
||||
|
||||
Schema::Object(object)
|
||||
}
|
||||
|
||||
/// Build the config schema for `config.toml`.
|
||||
pub fn config_schema() -> RootSchema {
|
||||
SchemaSettings::draft07()
|
||||
.with(|settings| {
|
||||
settings.option_add_null_type = false;
|
||||
})
|
||||
.into_generator()
|
||||
.into_root_schema_for::<ConfigToml>()
|
||||
}
|
||||
|
||||
/// Render the config schema as pretty-printed JSON.
|
||||
pub fn config_schema_json() -> anyhow::Result<Vec<u8>> {
|
||||
let schema = config_schema();
|
||||
let json = serde_json::to_vec_pretty(&schema)?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Write the config schema fixture to disk.
|
||||
pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> {
|
||||
let json = config_schema_json()?;
|
||||
std::fs::write(out_path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::config_schema_json;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use similar::TextDiff;
|
||||
|
||||
fn canonicalize(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
|
||||
Value::Object(map) => {
|
||||
let mut entries: Vec<_> = map.iter().collect();
|
||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
let mut sorted = Map::with_capacity(map.len());
|
||||
for (key, child) in entries {
|
||||
sorted.insert(key.clone(), canonicalize(child));
|
||||
}
|
||||
Value::Object(sorted)
|
||||
}
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_schema_matches_fixture() {
|
||||
let fixture_path = codex_utils_cargo_bin::find_resource!("config.schema.json")
|
||||
.expect("resolve config schema fixture path");
|
||||
let fixture = std::fs::read_to_string(fixture_path).expect("read config schema fixture");
|
||||
let fixture_value: serde_json::Value =
|
||||
serde_json::from_str(&fixture).expect("parse config schema fixture");
|
||||
let schema_json = config_schema_json().expect("serialize config schema");
|
||||
let schema_value: serde_json::Value =
|
||||
serde_json::from_slice(&schema_json).expect("decode schema json");
|
||||
let fixture_value = canonicalize(&fixture_value);
|
||||
let schema_value = canonicalize(&schema_value);
|
||||
if fixture_value != schema_value {
|
||||
let expected =
|
||||
serde_json::to_string_pretty(&fixture_value).expect("serialize fixture json");
|
||||
let actual =
|
||||
serde_json::to_string_pretty(&schema_value).expect("serialize schema json");
|
||||
let diff = TextDiff::from_lines(&expected, &actual)
|
||||
.unified_diff()
|
||||
.header("fixture", "generated")
|
||||
.to_string();
|
||||
panic!(
|
||||
"Current schema for `config.toml` doesn't match the fixture. \
|
||||
Run `just write-config-schema` to overwrite with your changes.\n\n{diff}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use wildmatch::WildMatchPattern;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
@@ -48,47 +49,51 @@ pub struct McpServerConfig {
|
||||
pub disabled_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// Raw MCP config shape used for deserialization and JSON Schema generation.
|
||||
// Keep this in sync with the validation logic in `McpServerConfig`.
|
||||
#[derive(Deserialize, Clone, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub(crate) struct RawMcpServerConfig {
|
||||
// stdio
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub env_vars: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub http_headers: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub env_http_headers: Option<HashMap<String, String>>,
|
||||
|
||||
// streamable_http
|
||||
pub url: Option<String>,
|
||||
pub bearer_token: Option<String>,
|
||||
pub bearer_token_env_var: Option<String>,
|
||||
|
||||
// shared
|
||||
#[serde(default)]
|
||||
pub startup_timeout_sec: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub startup_timeout_ms: Option<u64>,
|
||||
#[serde(default, with = "option_duration_secs")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub tool_timeout_sec: Option<Duration>,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub enabled_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub disabled_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct RawMcpServerConfig {
|
||||
// stdio
|
||||
command: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
env: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
env_vars: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
|
||||
// streamable_http
|
||||
url: Option<String>,
|
||||
bearer_token: Option<String>,
|
||||
bearer_token_env_var: Option<String>,
|
||||
|
||||
// shared
|
||||
#[serde(default)]
|
||||
startup_timeout_sec: Option<f64>,
|
||||
#[serde(default)]
|
||||
startup_timeout_ms: Option<u64>,
|
||||
#[serde(default, with = "option_duration_secs")]
|
||||
tool_timeout_sec: Option<Duration>,
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
enabled_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
disabled_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
let mut raw = RawMcpServerConfig::deserialize(deserializer)?;
|
||||
|
||||
let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) {
|
||||
@@ -164,7 +169,7 @@ const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
|
||||
pub enum McpServerTransportConfig {
|
||||
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio
|
||||
@@ -222,7 +227,7 @@ mod option_duration_secs {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)]
|
||||
pub enum UriBasedFileOpener {
|
||||
#[serde(rename = "vscode")]
|
||||
VsCode,
|
||||
@@ -254,7 +259,8 @@ impl UriBasedFileOpener {
|
||||
}
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct History {
|
||||
/// If true, history entries will not be written to disk.
|
||||
pub persistence: HistoryPersistence,
|
||||
@@ -264,7 +270,7 @@ pub struct History {
|
||||
pub max_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum HistoryPersistence {
|
||||
/// Save all history entries to disk.
|
||||
@@ -277,13 +283,15 @@ pub enum HistoryPersistence {
|
||||
// ===== Analytics configuration =====
|
||||
|
||||
/// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AnalyticsConfigToml {
|
||||
/// When `false`, disables analytics across Codex product surfaces in this profile.
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct FeedbackConfigToml {
|
||||
/// When `false`, disables the feedback flow across Codex product surfaces.
|
||||
pub enabled: Option<bool>,
|
||||
@@ -291,7 +299,7 @@ pub struct FeedbackConfigToml {
|
||||
|
||||
// ===== OTEL configuration =====
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OtelHttpProtocol {
|
||||
/// Binary payload
|
||||
@@ -300,7 +308,8 @@ pub enum OtelHttpProtocol {
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct OtelTlsConfig {
|
||||
pub ca_certificate: Option<AbsolutePathBuf>,
|
||||
@@ -309,7 +318,8 @@ pub struct OtelTlsConfig {
|
||||
}
|
||||
|
||||
/// Which OTEL exporter to use.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OtelExporterKind {
|
||||
None,
|
||||
@@ -332,7 +342,8 @@ pub enum OtelExporterKind {
|
||||
}
|
||||
|
||||
/// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct OtelConfigToml {
|
||||
/// Log user prompt in traces
|
||||
pub log_user_prompt: Option<bool>,
|
||||
@@ -369,7 +380,7 @@ impl Default for OtelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum Notifications {
|
||||
Enabled(bool),
|
||||
@@ -387,7 +398,7 @@ impl Default for Notifications {
|
||||
/// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse
|
||||
/// button events, without a magnitude. This setting controls whether Codex uses a heuristic to
|
||||
/// infer wheel vs trackpad per stream, or forces a specific behavior.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScrollInputMode {
|
||||
/// Infer wheel vs trackpad behavior per scroll stream.
|
||||
@@ -405,7 +416,8 @@ impl Default for ScrollInputMode {
|
||||
}
|
||||
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct Tui {
|
||||
/// Enable desktop notifications from the TUI when the terminal is unfocused.
|
||||
/// Defaults to `true`.
|
||||
@@ -544,7 +556,8 @@ const fn default_true() -> bool {
|
||||
/// Settings for notices we display to users via the tui and app-server clients
|
||||
/// (primarily the Codex IDE extension). NOTE: these are different from
|
||||
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct Notice {
|
||||
/// Tracks whether the user has acknowledged the full access warning prompt.
|
||||
pub hide_full_access_warning: Option<bool>,
|
||||
@@ -567,7 +580,8 @@ impl Notice {
|
||||
pub(crate) const TABLE_KEY: &'static str = "notice";
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
#[serde(default)]
|
||||
pub writable_roots: Vec<AbsolutePathBuf>,
|
||||
@@ -590,7 +604,7 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ShellEnvironmentPolicyInherit {
|
||||
/// "Core" environment variables for the platform. On UNIX, this would
|
||||
@@ -607,7 +621,8 @@ pub enum ShellEnvironmentPolicyInherit {
|
||||
|
||||
/// Policy for building the `env` when spawning a process via either the
|
||||
/// `shell` or `local_shell` tool.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ShellEnvironmentPolicyToml {
|
||||
pub inherit: Option<ShellEnvironmentPolicyInherit>,
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use crate::config::Constrained;
|
||||
@@ -43,6 +44,7 @@ impl fmt::Display for RequirementSource {
|
||||
pub struct ConfigRequirements {
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
pub sandbox_policy: Constrained<SandboxPolicy>,
|
||||
pub mcp_server_requirements: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
}
|
||||
|
||||
impl Default for ConfigRequirements {
|
||||
@@ -50,15 +52,29 @@ impl Default for ConfigRequirements {
|
||||
Self {
|
||||
approval_policy: Constrained::allow_any_from_default(),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly),
|
||||
mcp_server_requirements: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum McpServerIdentity {
|
||||
Command { command: String },
|
||||
Url { url: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpServerRequirement {
|
||||
pub identity: McpServerIdentity,
|
||||
}
|
||||
|
||||
/// Base config deserialized from /etc/codex/requirements.toml or MDM.
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
pub struct ConfigRequirementsToml {
|
||||
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
|
||||
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
|
||||
pub mcp_server_requirements: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
}
|
||||
|
||||
/// Value paired with the requirement source it came from, for better error
|
||||
@@ -87,6 +103,7 @@ impl<T> std::ops::Deref for Sourced<T> {
|
||||
pub struct ConfigRequirementsWithSources {
|
||||
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
|
||||
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
|
||||
pub mcp_server_requirements: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
}
|
||||
|
||||
impl ConfigRequirementsWithSources {
|
||||
@@ -114,7 +131,11 @@ impl ConfigRequirementsWithSources {
|
||||
self,
|
||||
other,
|
||||
source,
|
||||
{ allowed_approval_policies, allowed_sandbox_modes }
|
||||
{
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_server_requirements,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,10 +143,12 @@ impl ConfigRequirementsWithSources {
|
||||
let ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_server_requirements,
|
||||
} = self;
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
|
||||
mcp_server_requirements: mcp_server_requirements.map(|sourced| sourced.value),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +182,9 @@ impl From<SandboxMode> for SandboxModeRequirement {
|
||||
|
||||
impl ConfigRequirementsToml {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none()
|
||||
self.allowed_approval_policies.is_none()
|
||||
&& self.allowed_sandbox_modes.is_none()
|
||||
&& self.mcp_server_requirements.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +195,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
let ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_server_requirements,
|
||||
} = toml;
|
||||
|
||||
let approval_policy: Constrained<AskForApproval> = match allowed_approval_policies {
|
||||
@@ -247,6 +273,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
Ok(ConfigRequirements {
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
mcp_server_requirements: mcp_server_requirements.map(|sourced| sourced.value),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -264,12 +291,15 @@ mod tests {
|
||||
let ConfigRequirementsToml {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_server_requirements,
|
||||
} = toml;
|
||||
ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: allowed_approval_policies
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
mcp_server_requirements: mcp_server_requirements
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +319,7 @@ mod tests {
|
||||
let other = ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(allowed_approval_policies.clone()),
|
||||
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
|
||||
mcp_server_requirements: None,
|
||||
};
|
||||
|
||||
target.merge_unset_fields(source.clone(), other);
|
||||
@@ -301,6 +332,7 @@ mod tests {
|
||||
source.clone()
|
||||
)),
|
||||
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)),
|
||||
mcp_server_requirements: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -328,6 +360,7 @@ mod tests {
|
||||
source_location,
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_server_requirements: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
@@ -363,6 +396,7 @@ mod tests {
|
||||
existing_source,
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_server_requirements: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
@@ -523,4 +557,40 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_mcp_server_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[mcp_server_requirements.docs.identity]
|
||||
command = "codex-mcp"
|
||||
|
||||
[mcp_server_requirements.remote.identity]
|
||||
url = "https://example.com/mcp"
|
||||
"#;
|
||||
let requirements: ConfigRequirements =
|
||||
with_unknown_source(from_str(toml_str)?).try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.mcp_server_requirements,
|
||||
Some(BTreeMap::from([
|
||||
(
|
||||
"docs".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "codex-mcp".to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
"remote".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
]))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,12 @@ use toml::Value as TomlValue;
|
||||
|
||||
pub use config_requirements::ConfigRequirements;
|
||||
pub use config_requirements::ConfigRequirementsToml;
|
||||
pub use config_requirements::McpServerIdentity;
|
||||
pub use config_requirements::McpServerRequirement;
|
||||
pub use config_requirements::RequirementSource;
|
||||
pub use config_requirements::SandboxModeRequirement;
|
||||
pub use merge::merge_toml_values;
|
||||
pub(crate) use overrides::build_cli_overrides_layer;
|
||||
pub use state::ConfigLayerEntry;
|
||||
pub use state::ConfigLayerStack;
|
||||
pub use state::ConfigLayerStackOrdering;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
pub(super) fn default_empty_table() -> TomlValue {
|
||||
pub(crate) fn default_empty_table() -> TomlValue {
|
||||
TomlValue::Table(Default::default())
|
||||
}
|
||||
|
||||
pub(super) fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue {
|
||||
pub(crate) fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue {
|
||||
let mut root = default_empty_table();
|
||||
for (path, value) in cli_overrides {
|
||||
apply_toml_override(&mut root, path, value.clone());
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::shell::Shell;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
@@ -17,55 +12,12 @@ use std::path::PathBuf;
|
||||
#[serde(rename = "environment_context", rename_all = "snake_case")]
|
||||
pub(crate) struct EnvironmentContext {
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
pub network_access: Option<NetworkAccess>,
|
||||
pub writable_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub shell: Shell,
|
||||
}
|
||||
|
||||
impl EnvironmentContext {
|
||||
pub fn new(
|
||||
cwd: Option<PathBuf>,
|
||||
approval_policy: Option<AskForApproval>,
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
shell: Shell,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_mode: match sandbox_policy {
|
||||
Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess),
|
||||
Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly),
|
||||
Some(SandboxPolicy::ExternalSandbox { .. }) => Some(SandboxMode::DangerFullAccess),
|
||||
Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite),
|
||||
None => None,
|
||||
},
|
||||
network_access: match sandbox_policy {
|
||||
Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled),
|
||||
Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted),
|
||||
Some(SandboxPolicy::ExternalSandbox { network_access }) => Some(network_access),
|
||||
Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => {
|
||||
if network_access {
|
||||
Some(NetworkAccess::Enabled)
|
||||
} else {
|
||||
Some(NetworkAccess::Restricted)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
writable_roots: match sandbox_policy {
|
||||
Some(SandboxPolicy::WorkspaceWrite { writable_roots, .. }) => {
|
||||
if writable_roots.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(writable_roots)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
shell,
|
||||
}
|
||||
pub fn new(cwd: Option<PathBuf>, shell: Shell) -> Self {
|
||||
Self { cwd, shell }
|
||||
}
|
||||
|
||||
/// Compares two environment contexts, ignoring the shell. Useful when
|
||||
@@ -74,19 +26,11 @@ impl EnvironmentContext {
|
||||
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
|
||||
let EnvironmentContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
network_access,
|
||||
writable_roots,
|
||||
// should compare all fields except shell
|
||||
shell: _,
|
||||
} = other;
|
||||
|
||||
self.cwd == *cwd
|
||||
&& self.approval_policy == *approval_policy
|
||||
&& self.sandbox_mode == *sandbox_mode
|
||||
&& self.network_access == *network_access
|
||||
&& self.writable_roots == *writable_roots
|
||||
}
|
||||
|
||||
pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self {
|
||||
@@ -95,26 +39,11 @@ impl EnvironmentContext {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let approval_policy = if before.approval_policy != after.approval_policy {
|
||||
Some(after.approval_policy)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sandbox_policy = if before.sandbox_policy != after.sandbox_policy {
|
||||
Some(after.sandbox_policy.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone())
|
||||
EnvironmentContext::new(cwd, shell.clone())
|
||||
}
|
||||
|
||||
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
|
||||
Self::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
Some(turn_context.approval_policy),
|
||||
Some(turn_context.sandbox_policy.clone()),
|
||||
shell.clone(),
|
||||
)
|
||||
Self::new(Some(turn_context.cwd.clone()), shell.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +55,6 @@ impl EnvironmentContext {
|
||||
/// ```xml
|
||||
/// <environment_context>
|
||||
/// <cwd>...</cwd>
|
||||
/// <approval_policy>...</approval_policy>
|
||||
/// <sandbox_mode>...</sandbox_mode>
|
||||
/// <writable_roots>...</writable_roots>
|
||||
/// <network_access>...</network_access>
|
||||
/// <shell>...</shell>
|
||||
/// </environment_context>
|
||||
/// ```
|
||||
@@ -138,29 +63,6 @@ impl EnvironmentContext {
|
||||
if let Some(cwd) = self.cwd {
|
||||
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
||||
}
|
||||
if let Some(approval_policy) = self.approval_policy {
|
||||
lines.push(format!(
|
||||
" <approval_policy>{approval_policy}</approval_policy>"
|
||||
));
|
||||
}
|
||||
if let Some(sandbox_mode) = self.sandbox_mode {
|
||||
lines.push(format!(" <sandbox_mode>{sandbox_mode}</sandbox_mode>"));
|
||||
}
|
||||
if let Some(network_access) = self.network_access {
|
||||
lines.push(format!(
|
||||
" <network_access>{network_access}</network_access>"
|
||||
));
|
||||
}
|
||||
if let Some(writable_roots) = self.writable_roots {
|
||||
lines.push(" <writable_roots>".to_string());
|
||||
for writable_root in writable_roots {
|
||||
lines.push(format!(
|
||||
" <root>{}</root>",
|
||||
writable_root.to_string_lossy()
|
||||
));
|
||||
}
|
||||
lines.push(" </writable_roots>".to_string());
|
||||
}
|
||||
|
||||
let shell_name = self.shell.name();
|
||||
lines.push(format!(" <shell>{shell_name}</shell>"));
|
||||
@@ -187,7 +89,6 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use core_test_support::test_path_buf;
|
||||
use core_test_support::test_tmp_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn fake_shell() -> Shell {
|
||||
@@ -198,50 +99,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_write_policy(writable_roots: Vec<&str>, network_access: bool) -> SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: writable_roots
|
||||
.into_iter()
|
||||
.map(|s| AbsolutePathBuf::try_from(s).unwrap())
|
||||
.collect(),
|
||||
network_access,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_workspace_write_environment_context() {
|
||||
let cwd = test_path_buf("/repo");
|
||||
let writable_root = test_tmp_path_buf();
|
||||
let cwd_str = cwd.to_str().expect("cwd is valid utf-8");
|
||||
let writable_root_str = writable_root
|
||||
.to_str()
|
||||
.expect("writable root is valid utf-8");
|
||||
let context = EnvironmentContext::new(
|
||||
Some(cwd.clone()),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(
|
||||
vec![cwd_str, writable_root_str],
|
||||
false,
|
||||
)),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell());
|
||||
|
||||
let expected = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{cwd}</cwd>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>workspace-write</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
<writable_roots>
|
||||
<root>{cwd}</root>
|
||||
<root>{writable_root}</root>
|
||||
</writable_roots>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#,
|
||||
cwd = cwd.display(),
|
||||
writable_root = writable_root.display(),
|
||||
);
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
@@ -249,17 +117,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_read_only_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::Never),
|
||||
Some(SandboxPolicy::ReadOnly),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>read-only</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -268,19 +128,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_external_sandbox_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Enabled,
|
||||
}),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -289,19 +139,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_external_sandbox_with_restricted_network_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
}),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -310,17 +150,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_full_access_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::OnFailure),
|
||||
Some(SandboxPolicy::DangerFullAccess),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>on-failure</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -328,55 +160,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_approval_policy() {
|
||||
// Approval policy
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
fake_shell(),
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::Never),
|
||||
Some(workspace_write_policy(vec!["/repo"], true)),
|
||||
fake_shell(),
|
||||
);
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
fn equals_except_shell_compares_cwd() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_sandbox_policy() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_read_only_policy()),
|
||||
fake_shell(),
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_workspace_write_policy()),
|
||||
fake_shell(),
|
||||
);
|
||||
fn equals_except_shell_ignores_sandbox_policy() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_workspace_write_policy() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
|
||||
fake_shell(),
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
|
||||
fake_shell(),
|
||||
);
|
||||
fn equals_except_shell_compares_cwd_differences() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell());
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell());
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
@@ -385,8 +186,6 @@ mod tests {
|
||||
fn equals_except_shell_ignores_shell() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
Shell {
|
||||
shell_type: ShellType::Bash,
|
||||
shell_path: "/bin/bash".into(),
|
||||
@@ -395,8 +194,6 @@ mod tests {
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
Shell {
|
||||
shell_type: ShellType::Zsh,
|
||||
shell_path: "/bin/zsh".into(),
|
||||
|
||||
@@ -5,9 +5,10 @@ use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
|
||||
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::is_dangerous_command::command_might_be_dangerous;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use codex_execpolicy::AmendError;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error as ExecPolicyRuleError;
|
||||
@@ -45,19 +46,19 @@ fn is_policy_match(rule_match: &RuleMatch) -> bool {
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ExecPolicyError {
|
||||
#[error("failed to read execpolicy files from {dir}: {source}")]
|
||||
#[error("failed to read rules files from {dir}: {source}")]
|
||||
ReadDir {
|
||||
dir: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("failed to read execpolicy file {path}: {source}")]
|
||||
#[error("failed to read rules file {path}: {source}")]
|
||||
ReadFile {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("failed to parse execpolicy file {path}: {source}")]
|
||||
#[error("failed to parse rules file {path}: {source}")]
|
||||
ParsePolicy {
|
||||
path: String,
|
||||
source: codex_execpolicy::Error,
|
||||
@@ -66,19 +67,19 @@ pub enum ExecPolicyError {
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ExecPolicyUpdateError {
|
||||
#[error("failed to update execpolicy file {path}: {source}")]
|
||||
#[error("failed to update rules file {path}: {source}")]
|
||||
AppendRule { path: PathBuf, source: AmendError },
|
||||
|
||||
#[error("failed to join blocking execpolicy update task: {source}")]
|
||||
#[error("failed to join blocking rules update task: {source}")]
|
||||
JoinBlockingTask { source: tokio::task::JoinError },
|
||||
|
||||
#[error("failed to update in-memory execpolicy: {source}")]
|
||||
#[error("failed to update in-memory rules: {source}")]
|
||||
AddRule {
|
||||
#[from]
|
||||
source: ExecPolicyRuleError,
|
||||
},
|
||||
|
||||
#[error("cannot append execpolicy rule because execpolicy feature is disabled")]
|
||||
#[error("cannot append rule because rules feature is disabled")]
|
||||
FeatureDisabled,
|
||||
}
|
||||
|
||||
@@ -97,7 +98,11 @@ impl ExecPolicyManager {
|
||||
features: &Features,
|
||||
config_stack: &ConfigLayerStack,
|
||||
) -> Result<Self, ExecPolicyError> {
|
||||
let policy = load_exec_policy_for_features(features, config_stack).await?;
|
||||
let (policy, warning) =
|
||||
load_exec_policy_for_features_with_warning(features, config_stack).await?;
|
||||
if let Some(err) = warning.as_ref() {
|
||||
tracing::warn!("failed to parse rules: {err}");
|
||||
}
|
||||
Ok(Self::new(Arc::new(policy)))
|
||||
}
|
||||
|
||||
@@ -116,14 +121,15 @@ impl ExecPolicyManager {
|
||||
let exec_policy = self.current();
|
||||
let commands =
|
||||
parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
|
||||
let heuristics_fallback = |cmd: &[String]| {
|
||||
if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) {
|
||||
Decision::Prompt
|
||||
} else {
|
||||
Decision::Allow
|
||||
}
|
||||
let exec_policy_fallback = |cmd: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
cmd,
|
||||
sandbox_permissions,
|
||||
)
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple(commands.iter(), &heuristics_fallback);
|
||||
let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback);
|
||||
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
@@ -193,14 +199,26 @@ impl Default for ExecPolicyManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_exec_policy_for_features(
|
||||
pub async fn check_execpolicy_for_warnings(
|
||||
features: &Features,
|
||||
config_stack: &ConfigLayerStack,
|
||||
) -> Result<Policy, ExecPolicyError> {
|
||||
) -> Result<Option<ExecPolicyError>, ExecPolicyError> {
|
||||
let (_, warning) = load_exec_policy_for_features_with_warning(features, config_stack).await?;
|
||||
Ok(warning)
|
||||
}
|
||||
|
||||
async fn load_exec_policy_for_features_with_warning(
|
||||
features: &Features,
|
||||
config_stack: &ConfigLayerStack,
|
||||
) -> Result<(Policy, Option<ExecPolicyError>), ExecPolicyError> {
|
||||
if !features.enabled(Feature::ExecPolicy) {
|
||||
Ok(Policy::empty())
|
||||
} else {
|
||||
load_exec_policy(config_stack).await
|
||||
return Ok((Policy::empty(), None));
|
||||
}
|
||||
|
||||
match load_exec_policy(config_stack).await {
|
||||
Ok(policy) => Ok((policy, None)),
|
||||
Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,11 +255,75 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
|
||||
}
|
||||
|
||||
let policy = parser.build();
|
||||
tracing::debug!("loaded execpolicy from {} files", policy_paths.len());
|
||||
tracing::debug!("loaded rules from {} files", policy_paths.len());
|
||||
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
|
||||
pub fn render_decision_for_unmatched_command(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> Decision {
|
||||
if is_known_safe_command(command) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
|
||||
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
|
||||
// here.
|
||||
let runtime_sandbox_provides_safety =
|
||||
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly);
|
||||
|
||||
// If the command is flagged as dangerous or we have no sandbox protection,
|
||||
// we should never allow it to run without user approval.
|
||||
//
|
||||
// We prefer to prompt the user rather than outright forbid the command,
|
||||
// but if the user has explicitly disabled prompts, we must
|
||||
// forbid the command.
|
||||
if command_might_be_dangerous(command) || runtime_sandbox_provides_safety {
|
||||
return if matches!(approval_policy, AskForApproval::Never) {
|
||||
Decision::Forbidden
|
||||
} else {
|
||||
Decision::Prompt
|
||||
};
|
||||
}
|
||||
|
||||
match approval_policy {
|
||||
AskForApproval::Never | AskForApproval::OnFailure => {
|
||||
// We allow the command to run, relying on the sandbox for
|
||||
// protection.
|
||||
Decision::Allow
|
||||
}
|
||||
AskForApproval::UnlessTrusted => {
|
||||
// We already checked `is_known_safe_command(command)` and it
|
||||
// returned false, so we must prompt.
|
||||
Decision::Prompt
|
||||
}
|
||||
AskForApproval::OnRequest => {
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
|
||||
// The user has indicated we should "just run" commands
|
||||
// in their unrestricted environment, so we do so since the
|
||||
// command has not been flagged as dangerous.
|
||||
Decision::Allow
|
||||
}
|
||||
SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite { .. } => {
|
||||
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
|
||||
// non‑escalated, non‑dangerous commands — let the sandbox enforce
|
||||
// restrictions (e.g., block network/write) without a user prompt.
|
||||
if sandbox_permissions.requires_escalated_permissions() {
|
||||
Decision::Prompt
|
||||
} else {
|
||||
Decision::Allow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_policy_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
|
||||
}
|
||||
@@ -1051,4 +1133,108 @@ prefix_rule(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn vec_str(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
/// Note this test behaves differently on Windows because it exercises an
|
||||
/// `if cfg!(windows)` code path in render_decision_for_unmatched_command().
|
||||
#[tokio::test]
|
||||
async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
// `brew install powershell` to run this test on a Mac!
|
||||
// Note `pwsh` is required to parse a PowerShell command to see if it
|
||||
// is safe.
|
||||
if which::which("pwsh").is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = ExecPolicyManager::new(Arc::new(Policy::empty()));
|
||||
let features = Features::with_defaults();
|
||||
let permissions = SandboxPermissions::UseDefault;
|
||||
|
||||
// This command should not be run without user approval unless there is
|
||||
// a proper sandbox in place to ensure safety.
|
||||
let sneaky_command = vec_str(&["pwsh", "-Command", "echo hi @(calc)"]);
|
||||
let expected_amendment = Some(ExecPolicyAmendment::new(vec_str(&[
|
||||
"pwsh",
|
||||
"-Command",
|
||||
"echo hi @(calc)",
|
||||
])));
|
||||
let (pwsh_approval_reason, expected_req) = if cfg!(windows) {
|
||||
(
|
||||
r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean
|
||||
that no sandbox is present, so anything that is not "provably
|
||||
safe" should require approval."#,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: expected_amendment.clone(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"On non-Windows, rely on the read-only sandbox to prevent harm.",
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: expected_amendment.clone(),
|
||||
},
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
expected_req,
|
||||
policy
|
||||
.create_exec_approval_requirement_for_command(
|
||||
&features,
|
||||
&sneaky_command,
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
"{pwsh_approval_reason}"
|
||||
);
|
||||
|
||||
// This is flagged as a dangerous command on all platforms.
|
||||
let dangerous_command = vec_str(&["rm", "-rf", "/important/data"]);
|
||||
assert_eq!(
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
|
||||
"rm",
|
||||
"-rf",
|
||||
"/important/data",
|
||||
]))),
|
||||
},
|
||||
policy
|
||||
.create_exec_approval_requirement_for_command(
|
||||
&features,
|
||||
&dangerous_command,
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
r#"On all platforms, a forbidden command should require approval
|
||||
(unless AskForApproval::Never is specified)."#
|
||||
);
|
||||
|
||||
// A dangerous command should be forbidden if the user has specified
|
||||
// AskForApproval::Never.
|
||||
assert_eq!(
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "`rm -rf /important/data` rejected: blocked by policy".to_string(),
|
||||
},
|
||||
policy
|
||||
.create_exec_approval_requirement_for_command(
|
||||
&features,
|
||||
&dangerous_command,
|
||||
AskForApproval::Never,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
r#"On all platforms, a forbidden command should require approval
|
||||
(unless AskForApproval::Never is specified)."#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::profile::ConfigProfile;
|
||||
use codex_otel::OtelManager;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -15,6 +16,7 @@ use std::collections::BTreeSet;
|
||||
|
||||
mod legacy;
|
||||
pub(crate) use legacy::LegacyFeatureToggles;
|
||||
pub(crate) use legacy::legacy_feature_keys;
|
||||
|
||||
/// High-level lifecycle stage for a feature.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -96,6 +98,8 @@ pub enum Feature {
|
||||
EnableRequestCompression,
|
||||
/// Enable collab tools.
|
||||
Collab,
|
||||
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
|
||||
Steer,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
@@ -292,7 +296,7 @@ pub fn is_known_feature_key(key: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Deserializable features table for TOML.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
pub struct FeaturesToml {
|
||||
#[serde(flatten)]
|
||||
pub entries: BTreeMap<String, bool>,
|
||||
@@ -420,4 +424,14 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Steer,
|
||||
key: "steer",
|
||||
stage: Stage::Beta {
|
||||
name: "Steer conversation",
|
||||
menu_description: "Enter submits immediately; Tab queues messages when a task is running.",
|
||||
announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!",
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,6 +31,10 @@ const ALIASES: &[Alias] = &[
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn legacy_feature_keys() -> impl Iterator<Item = &'static str> {
|
||||
ALIASES.iter().map(|alias| alias.legacy_key)
|
||||
}
|
||||
|
||||
pub(crate) fn feature_for_key(key: &str) -> Option<Feature> {
|
||||
ALIASES
|
||||
.iter()
|
||||
|
||||
@@ -57,6 +57,7 @@ pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
|
||||
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
|
||||
pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::OLLAMA_CHAT_PROVIDER_ID;
|
||||
pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::WireApi;
|
||||
pub use model_provider_info::built_in_model_providers;
|
||||
@@ -113,6 +114,7 @@ pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||
pub use command_safety::is_dangerous_command;
|
||||
pub use command_safety::is_safe_command;
|
||||
pub use exec_policy::ExecPolicyError;
|
||||
pub use exec_policy::check_execpolicy_for_warnings;
|
||||
pub use exec_policy::load_exec_policy;
|
||||
pub use safety::get_platform_sandbox;
|
||||
pub use safety::is_windows_elevated_sandbox_enabled;
|
||||
@@ -126,6 +128,7 @@ pub use codex_protocol::protocol;
|
||||
pub use codex_protocol::config_types as protocol_config_types;
|
||||
|
||||
pub use client::ModelClient;
|
||||
pub use client::ModelClientSession;
|
||||
pub use client_common::Prompt;
|
||||
pub use client_common::REVIEW_PROMPT;
|
||||
pub use client_common::ResponseEvent;
|
||||
|
||||
@@ -47,7 +47,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
|
||||
mcp_connection_manager
|
||||
.initialize(
|
||||
config.mcp_servers.clone(),
|
||||
&config.mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_status_entries.clone(),
|
||||
tx_event,
|
||||
|
||||
@@ -312,7 +312,7 @@ pub(crate) struct McpConnectionManager {
|
||||
impl McpConnectionManager {
|
||||
pub async fn initialize(
|
||||
&mut self,
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
mcp_servers: &HashMap<String, McpServerConfig>,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
auth_entries: HashMap<String, McpAuthStatusEntry>,
|
||||
tx_event: Sender<Event>,
|
||||
@@ -325,6 +325,7 @@ impl McpConnectionManager {
|
||||
let mut clients = HashMap::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
let elicitation_requests = ElicitationRequestManager::default();
|
||||
let mcp_servers = mcp_servers.clone();
|
||||
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
|
||||
let cancel_token = cancel_token.child_token();
|
||||
let _ = emit_update(
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_app_server_protocol::AuthMode;
|
||||
use http::HeaderMap;
|
||||
use http::header::HeaderName;
|
||||
use http::header::HeaderValue;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
@@ -36,19 +37,24 @@ const OPENAI_PROVIDER_NAME: &str = "OpenAI";
|
||||
/// *Responses* API. The two protocols use different request/response shapes
|
||||
/// and *cannot* be auto-detected at runtime, therefore each provider entry
|
||||
/// must declare which one it expects.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WireApi {
|
||||
/// The Responses API exposed by OpenAI at `/v1/responses`.
|
||||
Responses,
|
||||
|
||||
/// Experimental: Responses API over WebSocket transport.
|
||||
#[serde(rename = "responses_websocket")]
|
||||
ResponsesWebsocket,
|
||||
|
||||
/// Regular Chat Completions compatible with `/v1/chat/completions`.
|
||||
#[default]
|
||||
Chat,
|
||||
}
|
||||
|
||||
/// Serializable representation of a provider definition.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ModelProviderInfo {
|
||||
/// Friendly display name.
|
||||
pub name: String,
|
||||
@@ -156,6 +162,7 @@ impl ModelProviderInfo {
|
||||
query_params: self.query_params.clone(),
|
||||
wire: match self.wire_api {
|
||||
WireApi::Responses => ApiWireApi::Responses,
|
||||
WireApi::ResponsesWebsocket => ApiWireApi::Responses,
|
||||
WireApi::Chat => ApiWireApi::Chat,
|
||||
},
|
||||
headers,
|
||||
@@ -260,6 +267,7 @@ pub const DEFAULT_OLLAMA_PORT: u16 = 11434;
|
||||
|
||||
pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio";
|
||||
pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama";
|
||||
pub const OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat";
|
||||
|
||||
/// Built-in default provider list.
|
||||
pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
@@ -273,6 +281,10 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
("openai", P::create_openai_provider()),
|
||||
(
|
||||
OLLAMA_OSS_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses),
|
||||
),
|
||||
(
|
||||
OLLAMA_CHAT_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -5,9 +5,105 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::io;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tracing::error;
|
||||
|
||||
/// Manages loading and saving of models cache to disk.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ModelsCacheManager {
|
||||
cache_path: PathBuf,
|
||||
cache_ttl: Duration,
|
||||
}
|
||||
|
||||
impl ModelsCacheManager {
|
||||
/// Create a new cache manager with the given path and TTL.
|
||||
pub(crate) fn new(cache_path: PathBuf, cache_ttl: Duration) -> Self {
|
||||
Self {
|
||||
cache_path,
|
||||
cache_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to load a fresh cache entry. Returns `None` if the cache doesn't exist or is stale.
|
||||
pub(crate) async fn load_fresh(&self) -> Option<ModelsCache> {
|
||||
let cache = match self.load().await {
|
||||
Ok(cache) => cache?,
|
||||
Err(err) => {
|
||||
error!("failed to load models cache: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !cache.is_fresh(self.cache_ttl) {
|
||||
return None;
|
||||
}
|
||||
Some(cache)
|
||||
}
|
||||
|
||||
/// Persist the cache to disk, creating parent directories as needed.
|
||||
pub(crate) async fn persist_cache(&self, models: &[ModelInfo], etag: Option<String>) {
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
etag,
|
||||
models: models.to_vec(),
|
||||
};
|
||||
if let Err(err) = self.save_internal(&cache).await {
|
||||
error!("failed to write models cache: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Renew the cache TTL by updating the fetched_at timestamp to now.
|
||||
pub(crate) async fn renew_cache_ttl(&self) -> io::Result<()> {
|
||||
let mut cache = match self.load().await? {
|
||||
Some(cache) => cache,
|
||||
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
|
||||
};
|
||||
cache.fetched_at = Utc::now();
|
||||
self.save_internal(&cache).await
|
||||
}
|
||||
|
||||
async fn load(&self) -> io::Result<Option<ModelsCache>> {
|
||||
match fs::read(&self.cache_path).await {
|
||||
Ok(contents) => {
|
||||
let cache = serde_json::from_slice(&contents)
|
||||
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
Ok(Some(cache))
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_internal(&self, cache: &ModelsCache) -> io::Result<()> {
|
||||
if let Some(parent) = self.cache_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(cache)
|
||||
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
fs::write(&self.cache_path, json).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Set the cache TTL.
|
||||
pub(crate) fn set_ttl(&mut self, ttl: Duration) {
|
||||
self.cache_ttl = ttl;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Manipulate cache file for testing. Allows setting a custom fetched_at timestamp.
|
||||
pub(crate) async fn manipulate_cache_for_test<F>(&self, f: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(&mut DateTime<Utc>),
|
||||
{
|
||||
let mut cache = match self.load().await? {
|
||||
Some(cache) => cache,
|
||||
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
|
||||
};
|
||||
f(&mut cache.fetched_at);
|
||||
self.save_internal(&cache).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized snapshot of models and metadata cached on disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -20,7 +116,7 @@ pub(crate) struct ModelsCache {
|
||||
|
||||
impl ModelsCache {
|
||||
/// Returns `true` when the cache entry has not exceeded the configured TTL.
|
||||
pub(crate) fn is_fresh(&self, ttl: Duration) -> bool {
|
||||
fn is_fresh(&self, ttl: Duration) -> bool {
|
||||
if ttl.is_zero() {
|
||||
return false;
|
||||
}
|
||||
@@ -31,26 +127,3 @@ impl ModelsCache {
|
||||
age <= ttl_duration
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and deserialize the cache file if it exists.
|
||||
pub(crate) async fn load_cache(path: &Path) -> io::Result<Option<ModelsCache>> {
|
||||
match fs::read(path).await {
|
||||
Ok(contents) => {
|
||||
let cache = serde_json::from_slice(&contents)
|
||||
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
Ok(Some(cache))
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the cache contents to disk, creating parent directories as needed.
|
||||
pub(crate) async fn save_cache(path: &Path, cache: &ModelsCache) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(cache)
|
||||
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
fs::write(path, json).await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use chrono::Utc;
|
||||
use codex_api::ModelsClient;
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
@@ -6,7 +5,6 @@ use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use http::HeaderMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -15,8 +13,7 @@ use tokio::sync::TryLockError;
|
||||
use tokio::time::timeout;
|
||||
use tracing::error;
|
||||
|
||||
use super::cache;
|
||||
use super::cache::ModelsCache;
|
||||
use super::cache::ModelsCacheManager;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::map_api_error;
|
||||
use crate::auth::AuthManager;
|
||||
@@ -36,6 +33,17 @@ const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.1-codex-max";
|
||||
const OPENAI_DEFAULT_CHATGPT_MODEL: &str = "gpt-5.2-codex";
|
||||
const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced";
|
||||
|
||||
/// Strategy for refreshing available models.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshStrategy {
|
||||
/// Always fetch from the network, ignoring cache.
|
||||
Online,
|
||||
/// Only use cached data, never fetch from the network.
|
||||
Offline,
|
||||
/// Use cache if available and fresh, otherwise fetch from the network.
|
||||
OnlineIfUncached,
|
||||
}
|
||||
|
||||
/// Coordinates remote model discovery plus cached metadata on disk.
|
||||
#[derive(Debug)]
|
||||
pub struct ModelsManager {
|
||||
@@ -43,64 +51,157 @@ pub struct ModelsManager {
|
||||
remote_models: RwLock<Vec<ModelInfo>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
etag: RwLock<Option<String>>,
|
||||
codex_home: PathBuf,
|
||||
cache_ttl: Duration,
|
||||
cache_manager: ModelsCacheManager,
|
||||
provider: ModelProviderInfo,
|
||||
}
|
||||
|
||||
impl ModelsManager {
|
||||
/// Construct a manager scoped to the provided `AuthManager`.
|
||||
///
|
||||
/// Uses `codex_home` to store cached model metadata and initializes with built-in presets.
|
||||
pub fn new(codex_home: PathBuf, auth_manager: Arc<AuthManager>) -> Self {
|
||||
let cache_path = codex_home.join(MODEL_CACHE_FILE);
|
||||
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
|
||||
Self {
|
||||
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
|
||||
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
|
||||
auth_manager,
|
||||
etag: RwLock::new(None),
|
||||
codex_home,
|
||||
cache_ttl: DEFAULT_MODEL_CACHE_TTL,
|
||||
cache_manager,
|
||||
provider: ModelProviderInfo::create_openai_provider(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Construct a manager scoped to the provided `AuthManager` with a specific provider. Used for integration tests.
|
||||
pub fn with_provider(
|
||||
codex_home: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
provider: ModelProviderInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
|
||||
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
|
||||
auth_manager,
|
||||
etag: RwLock::new(None),
|
||||
codex_home,
|
||||
cache_ttl: DEFAULT_MODEL_CACHE_TTL,
|
||||
provider,
|
||||
/// List all available models, refreshing according to the specified strategy.
|
||||
///
|
||||
/// Returns model presets sorted by priority and filtered by auth mode and visibility.
|
||||
pub async fn list_models(
|
||||
&self,
|
||||
config: &Config,
|
||||
refresh_strategy: RefreshStrategy,
|
||||
) -> Vec<ModelPreset> {
|
||||
if let Err(err) = self
|
||||
.refresh_available_models(config, refresh_strategy)
|
||||
.await
|
||||
{
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
let remote_models = self.get_remote_models(config).await;
|
||||
self.build_available_models(remote_models)
|
||||
}
|
||||
|
||||
/// Attempt to list models without blocking, using the current cached state.
|
||||
///
|
||||
/// Returns an error if the internal lock cannot be acquired.
|
||||
pub fn try_list_models(&self, config: &Config) -> Result<Vec<ModelPreset>, TryLockError> {
|
||||
let remote_models = self.try_get_remote_models(config)?;
|
||||
Ok(self.build_available_models(remote_models))
|
||||
}
|
||||
|
||||
// todo(aibrahim): should be visible to core only and sent on session_configured event
|
||||
/// Get the model identifier to use, refreshing according to the specified strategy.
|
||||
///
|
||||
/// If `model` is provided, returns it directly. Otherwise selects the default based on
|
||||
/// auth mode and available models (prefers `codex-auto-balanced` for ChatGPT auth).
|
||||
pub async fn get_default_model(
|
||||
&self,
|
||||
model: &Option<String>,
|
||||
config: &Config,
|
||||
refresh_strategy: RefreshStrategy,
|
||||
) -> String {
|
||||
if let Some(model) = model.as_ref() {
|
||||
return model.to_string();
|
||||
}
|
||||
if let Err(err) = self
|
||||
.refresh_available_models(config, refresh_strategy)
|
||||
.await
|
||||
{
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
// if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model
|
||||
let auth_mode = self.auth_manager.get_auth_mode();
|
||||
let remote_models = self.get_remote_models(config).await;
|
||||
if auth_mode == Some(AuthMode::ChatGPT) {
|
||||
let has_auto_balanced = self
|
||||
.build_available_models(remote_models)
|
||||
.iter()
|
||||
.any(|model| model.model == CODEX_AUTO_BALANCED_MODEL && model.show_in_picker);
|
||||
if has_auto_balanced {
|
||||
return CODEX_AUTO_BALANCED_MODEL.to_string();
|
||||
}
|
||||
return OPENAI_DEFAULT_CHATGPT_MODEL.to_string();
|
||||
}
|
||||
OPENAI_DEFAULT_API_MODEL.to_string()
|
||||
}
|
||||
|
||||
// todo(aibrahim): look if we can tighten it to pub(crate)
|
||||
/// Look up model metadata, applying remote overrides and config adjustments.
|
||||
pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo {
|
||||
let remote = self
|
||||
.get_remote_models(config)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|m| m.slug == model);
|
||||
let model = if let Some(remote) = remote {
|
||||
remote
|
||||
} else {
|
||||
model_info::find_model_info_for_slug(model)
|
||||
};
|
||||
model_info::with_config_overrides(model, config)
|
||||
}
|
||||
|
||||
/// Refresh models if the provided ETag differs from the cached ETag.
|
||||
///
|
||||
/// Uses `Online` strategy to fetch latest models when ETags differ.
|
||||
pub(crate) async fn refresh_if_new_etag(&self, etag: String, config: &Config) {
|
||||
let current_etag = self.get_etag().await;
|
||||
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
|
||||
if let Err(err) = self.cache_manager.renew_cache_ttl().await {
|
||||
error!("failed to renew cache TTL: {err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self
|
||||
.refresh_available_models(config, RefreshStrategy::Online)
|
||||
.await
|
||||
{
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the latest remote models, using the on-disk cache when still fresh.
|
||||
pub async fn refresh_available_models_with_cache(&self, config: &Config) -> CoreResult<()> {
|
||||
/// Refresh available models according to the specified strategy.
|
||||
async fn refresh_available_models(
|
||||
&self,
|
||||
config: &Config,
|
||||
refresh_strategy: RefreshStrategy,
|
||||
) -> CoreResult<()> {
|
||||
if !config.features.enabled(Feature::RemoteModels)
|
||||
|| self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if self.try_load_cache().await {
|
||||
return Ok(());
|
||||
|
||||
match refresh_strategy {
|
||||
RefreshStrategy::Offline => {
|
||||
// Only try to load from cache, never fetch
|
||||
self.try_load_cache().await;
|
||||
Ok(())
|
||||
}
|
||||
RefreshStrategy::OnlineIfUncached => {
|
||||
// Try cache first, fall back to online if unavailable
|
||||
if self.try_load_cache().await {
|
||||
return Ok(());
|
||||
}
|
||||
self.fetch_and_update_models().await
|
||||
}
|
||||
RefreshStrategy::Online => {
|
||||
// Always fetch from network
|
||||
self.fetch_and_update_models().await
|
||||
}
|
||||
}
|
||||
self.refresh_available_models_no_cache(config.features.enabled(Feature::RemoteModels))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_available_models_no_cache(
|
||||
&self,
|
||||
remote_models_feature: bool,
|
||||
) -> CoreResult<()> {
|
||||
if !remote_models_feature || self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) {
|
||||
return Ok(());
|
||||
}
|
||||
async fn fetch_and_update_models(&self) -> CoreResult<()> {
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
@@ -118,84 +219,10 @@ impl ModelsManager {
|
||||
|
||||
self.apply_remote_models(models.clone()).await;
|
||||
*self.etag.write().await = etag.clone();
|
||||
self.persist_cache(&models, etag).await;
|
||||
self.cache_manager.persist_cache(&models, etag).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_models(&self, config: &Config) -> Vec<ModelPreset> {
|
||||
if let Err(err) = self.refresh_available_models_with_cache(config).await {
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
let remote_models = self.remote_models(config).await;
|
||||
self.build_available_models(remote_models)
|
||||
}
|
||||
|
||||
pub fn try_list_models(&self, config: &Config) -> Result<Vec<ModelPreset>, TryLockError> {
|
||||
let remote_models = self.try_get_remote_models(config)?;
|
||||
Ok(self.build_available_models(remote_models))
|
||||
}
|
||||
|
||||
/// Look up the requested model metadata while applying remote metadata overrides.
|
||||
pub async fn construct_model_info(&self, model: &str, config: &Config) -> ModelInfo {
|
||||
let remote = self
|
||||
.remote_models(config)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|m| m.slug == model);
|
||||
let model = if let Some(remote) = remote {
|
||||
remote
|
||||
} else {
|
||||
model_info::find_model_info_for_slug(model)
|
||||
};
|
||||
model_info::with_config_overrides(model, config)
|
||||
}
|
||||
|
||||
pub async fn get_model(&self, model: &Option<String>, config: &Config) -> String {
|
||||
if let Some(model) = model.as_ref() {
|
||||
return model.to_string();
|
||||
}
|
||||
if let Err(err) = self.refresh_available_models_with_cache(config).await {
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
// if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model
|
||||
let auth_mode = self.auth_manager.get_auth_mode();
|
||||
let remote_models = self.remote_models(config).await;
|
||||
if auth_mode == Some(AuthMode::ChatGPT) {
|
||||
let has_auto_balanced = self
|
||||
.build_available_models(remote_models)
|
||||
.iter()
|
||||
.any(|model| model.model == CODEX_AUTO_BALANCED_MODEL && model.show_in_picker);
|
||||
if has_auto_balanced {
|
||||
return CODEX_AUTO_BALANCED_MODEL.to_string();
|
||||
}
|
||||
return OPENAI_DEFAULT_CHATGPT_MODEL.to_string();
|
||||
}
|
||||
OPENAI_DEFAULT_API_MODEL.to_string()
|
||||
}
|
||||
pub async fn refresh_if_new_etag(&self, etag: String, remote_models_feature: bool) {
|
||||
let current_etag = self.get_etag().await;
|
||||
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self
|
||||
.refresh_available_models_no_cache(remote_models_feature)
|
||||
.await
|
||||
{
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn get_model_offline(model: Option<&str>) -> String {
|
||||
model.unwrap_or(OPENAI_DEFAULT_CHATGPT_MODEL).to_string()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Offline helper that builds a `ModelInfo` without consulting remote state.
|
||||
pub fn construct_model_info_offline(model: &str, config: &Config) -> ModelInfo {
|
||||
model_info::with_config_overrides(model_info::find_model_info_for_slug(model), config)
|
||||
}
|
||||
|
||||
async fn get_etag(&self) -> Option<String> {
|
||||
self.etag.read().await.clone()
|
||||
}
|
||||
@@ -213,49 +240,25 @@ impl ModelsManager {
|
||||
|
||||
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
|
||||
async fn try_load_cache(&self) -> bool {
|
||||
// todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk
|
||||
let cache_path = self.cache_path();
|
||||
let cache = match cache::load_cache(&cache_path).await {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => {
|
||||
error!("failed to load models cache: {err}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let cache = match cache {
|
||||
let cache = match self.cache_manager.load_fresh().await {
|
||||
Some(cache) => cache,
|
||||
None => return false,
|
||||
};
|
||||
if !cache.is_fresh(self.cache_ttl) {
|
||||
return false;
|
||||
}
|
||||
let models = cache.models.clone();
|
||||
*self.etag.write().await = cache.etag.clone();
|
||||
self.apply_remote_models(models.clone()).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Serialize the latest fetch to disk for reuse across future processes.
|
||||
async fn persist_cache(&self, models: &[ModelInfo], etag: Option<String>) {
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
etag,
|
||||
models: models.to_vec(),
|
||||
};
|
||||
let cache_path = self.cache_path();
|
||||
if let Err(err) = cache::save_cache(&cache_path, &cache).await {
|
||||
error!("failed to write models cache: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge remote model metadata into picker-ready presets, preserving existing entries.
|
||||
fn build_available_models(&self, mut remote_models: Vec<ModelInfo>) -> Vec<ModelPreset> {
|
||||
remote_models.sort_by(|a, b| a.priority.cmp(&b.priority));
|
||||
|
||||
let remote_presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
|
||||
let existing_presets = self.local_models.clone();
|
||||
let mut merged_presets = Self::merge_presets(remote_presets, existing_presets);
|
||||
merged_presets = self.filter_visible_models(merged_presets);
|
||||
let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets);
|
||||
let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT);
|
||||
merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode);
|
||||
|
||||
let has_default = merged_presets.iter().any(|preset| preset.is_default);
|
||||
if !has_default {
|
||||
@@ -272,40 +275,7 @@ impl ModelsManager {
|
||||
merged_presets
|
||||
}
|
||||
|
||||
fn filter_visible_models(&self, models: Vec<ModelPreset>) -> Vec<ModelPreset> {
|
||||
let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT);
|
||||
models
|
||||
.into_iter()
|
||||
.filter(|model| chatgpt_mode || model.supported_in_api)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn merge_presets(
|
||||
remote_presets: Vec<ModelPreset>,
|
||||
existing_presets: Vec<ModelPreset>,
|
||||
) -> Vec<ModelPreset> {
|
||||
if remote_presets.is_empty() {
|
||||
return existing_presets;
|
||||
}
|
||||
|
||||
let remote_slugs: HashSet<&str> = remote_presets
|
||||
.iter()
|
||||
.map(|preset| preset.model.as_str())
|
||||
.collect();
|
||||
|
||||
let mut merged_presets = remote_presets.clone();
|
||||
for mut preset in existing_presets {
|
||||
if remote_slugs.contains(preset.model.as_str()) {
|
||||
continue;
|
||||
}
|
||||
preset.is_default = false;
|
||||
merged_presets.push(preset);
|
||||
}
|
||||
|
||||
merged_presets
|
||||
}
|
||||
|
||||
async fn remote_models(&self, config: &Config) -> Vec<ModelInfo> {
|
||||
async fn get_remote_models(&self, config: &Config) -> Vec<ModelInfo> {
|
||||
if config.features.enabled(Feature::RemoteModels) {
|
||||
self.remote_models.read().await.clone()
|
||||
} else {
|
||||
@@ -321,8 +291,35 @@ impl ModelsManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_path(&self) -> PathBuf {
|
||||
self.codex_home.join(MODEL_CACHE_FILE)
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Construct a manager with a specific provider for testing.
|
||||
pub fn with_provider(
|
||||
codex_home: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
provider: ModelProviderInfo,
|
||||
) -> Self {
|
||||
let cache_path = codex_home.join(MODEL_CACHE_FILE);
|
||||
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
|
||||
Self {
|
||||
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
|
||||
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
|
||||
auth_manager,
|
||||
etag: RwLock::new(None),
|
||||
cache_manager,
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Get model identifier without consulting remote state or cache.
|
||||
pub fn get_model_offline(model: Option<&str>) -> String {
|
||||
model.unwrap_or(OPENAI_DEFAULT_CHATGPT_MODEL).to_string()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Build `ModelInfo` without consulting remote state or cache.
|
||||
pub fn construct_model_info_offline(model: &str, config: &Config) -> ModelInfo {
|
||||
model_info::with_config_overrides(model_info::find_model_info_for_slug(model), config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,13 +335,13 @@ fn format_client_version_to_whole() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::cache::ModelsCache;
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::auth::AuthCredentialsStoreMode;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::features::Feature;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -434,13 +431,15 @@ mod tests {
|
||||
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
|
||||
|
||||
manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
let cached_remote = manager.remote_models(&config).await;
|
||||
let cached_remote = manager.get_remote_models(&config).await;
|
||||
assert_eq!(cached_remote, remote_models);
|
||||
|
||||
let available = manager.list_models(&config).await;
|
||||
let available = manager
|
||||
.list_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
let high_idx = available
|
||||
.iter()
|
||||
.position(|model| model.model == "priority-high")
|
||||
@@ -494,22 +493,22 @@ mod tests {
|
||||
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
|
||||
|
||||
manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("first refresh succeeds");
|
||||
assert_eq!(
|
||||
manager.remote_models(&config).await,
|
||||
manager.get_remote_models(&config).await,
|
||||
remote_models,
|
||||
"remote cache should store fetched models"
|
||||
);
|
||||
|
||||
// Second call should read from cache and avoid the network.
|
||||
manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("cached refresh succeeds");
|
||||
assert_eq!(
|
||||
manager.remote_models(&config).await,
|
||||
manager.get_remote_models(&config).await,
|
||||
remote_models,
|
||||
"cache path should not mutate stored models"
|
||||
);
|
||||
@@ -549,19 +548,18 @@ mod tests {
|
||||
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
|
||||
|
||||
manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("initial refresh succeeds");
|
||||
|
||||
// Rewrite cache with an old timestamp so it is treated as stale.
|
||||
let cache_path = codex_home.path().join(MODEL_CACHE_FILE);
|
||||
let contents =
|
||||
std::fs::read_to_string(&cache_path).expect("cache file should exist after refresh");
|
||||
let mut cache: ModelsCache =
|
||||
serde_json::from_str(&contents).expect("cache should deserialize");
|
||||
cache.fetched_at = Utc::now() - chrono::Duration::hours(1);
|
||||
std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap())
|
||||
.expect("cache rewrite succeeds");
|
||||
manager
|
||||
.cache_manager
|
||||
.manipulate_cache_for_test(|fetched_at| {
|
||||
*fetched_at = Utc::now() - chrono::Duration::hours(1);
|
||||
})
|
||||
.await
|
||||
.expect("cache manipulation succeeds");
|
||||
|
||||
let updated_models = vec![remote_model("fresh", "Fresh", 9)];
|
||||
server.reset().await;
|
||||
@@ -574,11 +572,11 @@ mod tests {
|
||||
.await;
|
||||
|
||||
manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("second refresh succeeds");
|
||||
assert_eq!(
|
||||
manager.remote_models(&config).await,
|
||||
manager.get_remote_models(&config).await,
|
||||
updated_models,
|
||||
"stale cache should trigger refetch"
|
||||
);
|
||||
@@ -618,10 +616,10 @@ mod tests {
|
||||
let provider = provider_for(server.uri());
|
||||
let mut manager =
|
||||
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
|
||||
manager.cache_ttl = Duration::ZERO;
|
||||
manager.cache_manager.set_ttl(Duration::ZERO);
|
||||
|
||||
manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("initial refresh succeeds");
|
||||
|
||||
@@ -636,7 +634,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
manager
|
||||
.refresh_available_models_with_cache(&config)
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("second refresh succeeds");
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelUpgrade;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use indoc::indoc;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt";
|
||||
@@ -318,6 +319,16 @@ fn gpt_52_codex_upgrade() -> ModelUpgrade {
|
||||
"Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work."
|
||||
.to_string(),
|
||||
),
|
||||
migration_markdown: Some(
|
||||
indoc! {r#"
|
||||
**Codex just got an upgrade. Introducing {model_to}.**
|
||||
|
||||
Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex
|
||||
|
||||
You can continue using {model_from} if you prefer.
|
||||
"#}
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,6 +206,7 @@ mod tests {
|
||||
RolloutItem::ResponseItem(items[0].clone()),
|
||||
RolloutItem::ResponseItem(items[1].clone()),
|
||||
RolloutItem::ResponseItem(items[2].clone()),
|
||||
RolloutItem::ResponseItem(items[3].clone()),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
|
||||
pub(crate) mcp_startup_cancellation_token: CancellationToken,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
|
||||
pub(crate) notifier: UserNotifier,
|
||||
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
|
||||
|
||||
@@ -104,6 +104,10 @@ impl TurnState {
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_input(&self) -> bool {
|
||||
!self.pending_input.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveTurn {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user