diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ea12d29eead6..b0077619b602 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1085,6 +1085,8 @@ "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4_process_security_environment_spec": "{\"dependencies\":[{\"name\":\"flatbuffers\",\"req\":\"25\"}],\"features\":{},\"strip_prefix\":\"core/generated/process_security_environment_specification\"}", "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4_sandbox_spec": "{\"dependencies\":[{\"name\":\"flatbuffers\",\"req\":\"25\"}],\"features\":{},\"strip_prefix\":\"core/generated/base_container_specification\"}", "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4_wxc_common": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"0.22\"},{\"name\":\"cidr\",\"req\":\"0.3.2\"},{\"name\":\"getrandom\",\"req\":\"0.2\"},{\"default_features\":true,\"features\":[],\"name\":\"mxc_config_contract\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"mxc_schema_support\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"mxc_telemetry\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"nanvix_common\",\"optional\":true},{\"name\":\"schemars\",\"optional\":true,\"req\":\"0.8\"},{\"name\":\"semver\",\"req\":\"1\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false,\"req\":\"1\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"req\":\"1\"},{\"name\":\"serde_path_to_error\",\"req\":\"0.1\"},{\"name\":\"thiserror\",\"req\":\"2\"},{\"name\":\"unicode-general-category\",\"req\":\"1\"},{\"name\":\"url\",\"req\":\"2\"},{\"default_features\":true,\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"1\"},{\"name\":\"widestring\",\"req\":\"1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":true,\"features\":[\"Win32_Foundation\",\"Win32_Globalization\",\"Win32_NetworkManagement_WindowsFirewall\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Security_Authorization\",\"Win32_Security_Credentials\",\"Win32_Security_Isolation\",\"Win32_Storage_FileSystem\",\"Win32_System_Com\",\"Win32_System_Console\",\"Win32_System_Diagnostics_Debug\",\"Win32_System_Diagnostics_Etw\",\"Win32_System_Diagnostics_ToolHelp\",\"Win32_System_Environment\",\"Win32_System_IO\",\"Win32_System_JobObjects\",\"Win32_System_LibraryLoader\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_System_Pipes\",\"Win32_System_ProcessStatus\",\"Win32_System_Registry\",\"Win32_System_SystemInformation\",\"Win32_System_SystemServices\",\"Win32_System_Threading\",\"Win32_System_Time\",\"Win32_System_Variant\",\"Win32_System_WindowsProgramming\",\"Win32_UI_Shell\"],\"name\":\"windows\",\"optional\":false,\"req\":\"0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"windows-core\",\"req\":\"0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"winreg\",\"req\":\"0.55\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"libc\",\"req\":\"0.2\",\"target\":\"cfg(unix)\"}],\"features\":{\"microvm\":[\"dep:nanvix_common\",\"dep:uuid\"],\"schema-gen\":[\"dep:mxc_schema_support\",\"dep:schemars\"]},\"strip_prefix\":\"core/wxc_common\"}", + "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df_rmcp": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"async-trait\",\"optional\":true,\"req\":\"0.1.89\"},{\"default_features\":true,\"features\":[],\"name\":\"base64\",\"optional\":true,\"req\":\"0.23\"},{\"default_features\":true,\"features\":[],\"name\":\"bytes\",\"optional\":true,\"req\":\"1\"},{\"name\":\"futures\",\"req\":\"0.3\"},{\"default_features\":true,\"features\":[],\"name\":\"hmac\",\"optional\":true,\"req\":\"0.13\"},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true,\"req\":\"1\"},{\"default_features\":true,\"features\":[],\"name\":\"http-body\",\"optional\":true,\"req\":\"1\"},{\"default_features\":true,\"features\":[],\"name\":\"http-body-util\",\"optional\":true,\"req\":\"0.1\"},{\"default_features\":true,\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"1\"},{\"default_features\":true,\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"0.1\"},{\"default_features\":true,\"features\":[\"serde\"],\"name\":\"indexmap\",\"optional\":false,\"req\":\"2\"},{\"default_features\":true,\"features\":[\"aws_lc_rs\"],\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"11\"},{\"default_features\":false,\"features\":[],\"name\":\"oauth2\",\"optional\":true,\"req\":\"5.0\"},{\"default_features\":true,\"features\":[],\"name\":\"pastey\",\"optional\":true,\"req\":\"0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"0.2\"},{\"default_features\":true,\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"10.0\"},{\"default_features\":true,\"features\":[],\"name\":\"rand\",\"optional\":true,\"req\":\"0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"0.13.2\"},{\"default_features\":true,\"features\":[],\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"3.3.0\"},{\"default_features\":true,\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"1.0\"},{\"default_features\":true,\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":false,\"req\":\"1.0\"},{\"name\":\"serde_json\",\"req\":\"1.0\"},{\"default_features\":true,\"features\":[],\"name\":\"sha2\",\"optional\":true,\"req\":\"0.11\"},{\"default_features\":true,\"features\":[],\"name\":\"sse-stream\",\"optional\":true,\"req\":\"0.2.4\"},{\"name\":\"thiserror\",\"req\":\"2\"},{\"default_features\":true,\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"optional\":false,\"req\":\"1\"},{\"default_features\":true,\"features\":[],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"0.1\"},{\"default_features\":true,\"features\":[],\"name\":\"tokio-util\",\"optional\":false,\"req\":\"0.7\"},{\"default_features\":true,\"features\":[],\"name\":\"tower-service\",\"optional\":true,\"req\":\"0.3\"},{\"default_features\":true,\"features\":[],\"name\":\"tracing\",\"optional\":false,\"req\":\"0.1\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true,\"req\":\"2.4\"},{\"default_features\":true,\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"1\"},{\"default_features\":true,\"features\":[],\"name\":\"which\",\"optional\":true,\"req\":\"8\"},{\"default_features\":true,\"features\":[],\"name\":\"zeroize\",\"optional\":true,\"req\":\"1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"optional\":false,\"req\":\"0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"optional\":false,\"req\":\"0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:async-trait\",\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"auth-enterprise-managed\":[\"auth\",\"base64\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\",\"dep:bytes\",\"base64\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"request-state\":[\"dep:hmac\",\"dep:sha2\",\"dep:zeroize\",\"base64\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"schemars\",\"dep:pastey\",\"uuid\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\",\"base64\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:async-trait\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]},\"strip_prefix\":\"./crates/rmcp\"}", + "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df_rmcp-macros": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"darling\",\"optional\":false,\"req\":\"0.24\"},{\"name\":\"proc-macro2\",\"req\":\"1\"},{\"name\":\"quote\",\"req\":\"1\"},{\"name\":\"serde_json\",\"req\":\"1.0\"},{\"default_features\":true,\"features\":[\"full\"],\"name\":\"syn\",\"optional\":false,\"req\":\"3\"}],\"features\":{\"local\":[]},\"strip_prefix\":\"./crates/rmcp-macros\"}", "git+https://github.com/openai-oss-forks/crossterm?rev=efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4#efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"base64\",\"optional\":true,\"req\":\"0.22\"},{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false,\"req\":\"2.9\"},{\"default_features\":true,\"features\":[\"is_variant\"],\"name\":\"derive_more\",\"optional\":true,\"req\":\"2.0.0\"},{\"name\":\"document-features\",\"req\":\"0.2.11\"},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true,\"req\":\"0.3\"},{\"name\":\"parking_lot\",\"req\":\"0.12\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"1.0\"},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"req\":\"0.8\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"req\":\"0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"req\":\"1.0\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"req\":\"1\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"req\":\"0.3.17\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"req\":\"0.2.4\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"req\":\"0.9.1\",\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"req\":\"0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"events\",\"windows\",\"derive-more\"],\"derive-more\":[\"dep:derive_more\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"osc52\":[\"dep:base64\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib-rs\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", @@ -1508,7 +1510,7 @@ "proc-macro-error-attr2_2.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"}],\"features\":{}}", "proc-macro-error2_2.0.1": "{\"dependencies\":[{\"name\":\"proc-macro-error-attr2\",\"req\":\"=2.0.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.99\"}],\"features\":{\"default\":[\"syn-error\"],\"nightly\":[],\"syn-error\":[\"dep:syn\"]}}", "proc-macro2_1.0.106": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"proc-macro\"],\"nightly\":[],\"proc-macro\":[],\"span-locations\":[]}}", - "process-wrap_9.0.1": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"default_features\":false,\"features\":[\"fs\",\"poll\",\"signal\"],\"name\":\"nix\",\"optional\":true,\"req\":\"^0.30.1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"remoteprocess\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.20.0\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38.2\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38.2\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"name\":\"windows\",\"optional\":true,\"req\":\"^0.62.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"creation-flags\":[\"dep:windows\",\"windows/Win32_System_Threading\"],\"default\":[\"creation-flags\",\"job-object\",\"kill-on-drop\",\"process-group\",\"process-session\",\"tracing\"],\"job-object\":[\"dep:windows\",\"windows/Win32_Security\",\"windows/Win32_System_Diagnostics_ToolHelp\",\"windows/Win32_System_IO\",\"windows/Win32_System_JobObjects\",\"windows/Win32_System_Threading\"],\"kill-on-drop\":[],\"process-group\":[],\"process-session\":[\"process-group\"],\"reset-sigmask\":[],\"std\":[\"dep:nix\"],\"tokio1\":[\"dep:nix\",\"dep:futures\",\"dep:tokio\"],\"tracing\":[\"dep:tracing\"]}}", + "process-wrap_10.0.0": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"default_features\":false,\"features\":[\"fs\",\"poll\",\"signal\"],\"name\":\"nix\",\"optional\":true,\"req\":\"^0.31.1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"remoteprocess\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.20.0\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38.2\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38.2\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"name\":\"windows\",\"optional\":true,\"req\":\"^0.62.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"creation-flags\":[\"dep:windows\",\"windows/Win32_System_Threading\"],\"default\":[\"creation-flags\",\"job-object\",\"kill-on-drop\",\"process-group\",\"process-session\",\"tracing\"],\"job-object\":[\"dep:windows\",\"windows/Win32_Security\",\"windows/Win32_System_Diagnostics_ToolHelp\",\"windows/Win32_System_IO\",\"windows/Win32_System_JobObjects\",\"windows/Win32_System_Threading\"],\"kill-on-drop\":[],\"process-group\":[],\"process-session\":[\"process-group\"],\"reset-sigmask\":[],\"std\":[\"dep:nix\"],\"tokio1\":[\"dep:nix\",\"dep:futures\",\"dep:tokio\"],\"tracing\":[\"dep:tracing\"]}}", "prodash_31.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"async-executor\",\"req\":\"^1.1.0\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2.2.1\"},{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.2.1\"},{\"kind\":\"dev\",\"name\":\"blocking\",\"req\":\"^1.0.0\"},{\"name\":\"bytesize\",\"optional\":true,\"req\":\"^2.0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.1\"},{\"default_features\":false,\"name\":\"crosstermion\",\"optional\":true,\"req\":\"^0.16.0\"},{\"default_features\":false,\"features\":[\"termination\"],\"name\":\"ctrlc\",\"optional\":true,\"req\":\"^3.1.4\"},{\"default_features\":false,\"name\":\"dashmap\",\"optional\":true,\"req\":\"^6.0.1\"},{\"default_features\":false,\"features\":[\"humantime\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.4\"},{\"name\":\"futures-lite\",\"optional\":true,\"req\":\"^2.1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.4\"},{\"name\":\"human_format\",\"optional\":true,\"req\":\"^1.0.3\"},{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4.9\"},{\"kind\":\"dev\",\"name\":\"is-terminal\",\"req\":\"^0.4.9\"},{\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.4.0\"},{\"default_features\":false,\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.1\"},{\"default_features\":false,\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"tui\",\"optional\":true,\"package\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"tui-react\",\"optional\":true,\"req\":\"^0.24.0\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.6.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.2\"}],\"features\":{\"default\":[\"progress-tree\"],\"local-time\":[\"jiff\"],\"progress-log\":[\"log\"],\"progress-tree\":[\"parking_lot\"],\"progress-tree-hp-hashmap\":[\"dashmap\"],\"progress-tree-log\":[\"log\"],\"render-line\":[\"crosstermion/color\",\"jiff\",\"unicode-width\"],\"render-line-autoconfigure\":[\"is-terminal\"],\"render-line-crossterm\":[\"crosstermion/crossterm\"],\"render-tui\":[\"tui\",\"unicode-segmentation\",\"unicode-width\",\"crosstermion/input-async\",\"tui-react\",\"futures-lite\",\"futures-core\",\"async-io\",\"jiff\"],\"render-tui-crossterm\":[\"crosstermion/tui-react-crossterm\",\"crosstermion/input-async-crossterm\"],\"unit-bytes\":[\"bytesize\"],\"unit-duration\":[\"jiff\"],\"unit-human\":[\"human_format\"]}}", "proptest_1.9.0": "{\"dependencies\":[{\"name\":\"bit-set\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bitflags\",\"req\":\"^2.9\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.15\"},{\"name\":\"proptest-macro\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"req\":\"^0.9\"},{\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rusty-fork\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"=1.0.112\"},{\"name\":\"unarray\",\"req\":\"^0.1.4\"},{\"name\":\"x86\",\"optional\":true,\"req\":\"^0.52.0\"}],\"features\":{\"alloc\":[],\"atomic64bit\":[],\"attr-macro\":[\"proptest-macro\"],\"bit-set\":[\"dep:bit-set\",\"dep:bit-vec\"],\"default\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"default-code-coverage\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"fork\":[\"std\",\"rusty-fork\",\"tempfile\"],\"handle-panics\":[\"std\"],\"hardware-rng\":[\"x86\"],\"no_std\":[\"num-traits/libm\"],\"std\":[\"rand/std\",\"rand/os_rng\",\"regex-syntax\",\"num-traits/std\"],\"timeout\":[\"fork\",\"rusty-fork/timeout\"],\"unstable\":[]}}", "prost-build_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.14\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"petgraph\",\"req\":\"^0.8\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^22\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}", @@ -1615,8 +1617,6 @@ "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", "rkyv_0.8.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"arrayvec-0_7\",\"optional\":true,\"package\":\"arrayvec\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"simdutf8\"],\"name\":\"bytecheck\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bytes-1\",\"optional\":true,\"package\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"name\":\"hashbrown-0_14\",\"optional\":true,\"package\":\"hashbrown\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"hashbrown-0_15\",\"optional\":true,\"package\":\"hashbrown\",\"req\":\"^0.15\"},{\"default_features\":false,\"name\":\"hashbrown-0_16\",\"optional\":true,\"package\":\"hashbrown\",\"req\":\"^0.16\"},{\"default_features\":false,\"name\":\"indexmap-2\",\"optional\":true,\"package\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"munge\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"ptr_meta\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rancor\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"rend\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"rkyv_derive\",\"req\":\"=0.8.18\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"smallvec-1\",\"optional\":true,\"package\":\"smallvec\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"smol_str-0_2\",\"optional\":true,\"package\":\"smol_str\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"smol_str-0_3\",\"optional\":true,\"package\":\"smol_str\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"thin-vec-0_2\",\"optional\":true,\"package\":\"thin-vec\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"name\":\"tinyvec-1\",\"optional\":true,\"package\":\"tinyvec\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"triomphe-0_1\",\"optional\":true,\"package\":\"triomphe\",\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"uuid-1\",\"optional\":true,\"package\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"aligned\":[],\"alloc\":[\"dep:hashbrown\",\"tinyvec-1?/alloc\",\"rancor/alloc\"],\"big_endian\":[],\"bytecheck\":[\"dep:bytecheck\",\"rend/bytecheck\",\"rkyv_derive/bytecheck\"],\"default\":[\"std\",\"bytecheck\"],\"hashbrown-0_17\":[\"dep:hashbrown\"],\"indexmap-2\":[\"dep:indexmap-2\",\"alloc\"],\"little_endian\":[],\"pointer_width_16\":[],\"pointer_width_32\":[],\"pointer_width_64\":[],\"std\":[\"alloc\",\"bytes-1?/std\",\"indexmap-2?/std\",\"ptr_meta/std\",\"uuid-1?/std\"],\"triomphe-0_1\":[\"dep:triomphe-0_1\",\"alloc\"],\"unaligned\":[],\"uuid-1\":[\"dep:uuid-1\",\"bytecheck?/uuid-1\"]}}", "rkyv_derive_0.8.18": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^3.0\"}],\"features\":{\"bytecheck\":[],\"default\":[]}}", - "rmcp-macros_3.2.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.24\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^3\"}],\"features\":{\"local\":[]}}", - "rmcp_3.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"features\":[\"aws_lc_rs\"],\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^11\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^3.2.0\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2.4\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:async-trait\",\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\",\"dep:bytes\",\"base64\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"request-state\":[\"dep:hmac\",\"dep:sha2\",\"dep:zeroize\",\"base64\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"schemars\",\"dep:pastey\",\"uuid\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\",\"base64\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:async-trait\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", "rouille_3.6.2": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.13\"},{\"name\":\"brotli\",\"optional\":true,\"req\":\"^3.3.2\"},{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"req\":\"^0.4.19\"},{\"features\":[\"gzip\"],\"name\":\"deflate\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"filetime\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"server\"],\"name\":\"multipart\",\"req\":\"^0.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postgres\",\"req\":\"^0.19\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_derive\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"sha1_smol\",\"req\":\"^1.0.0\"},{\"name\":\"threadpool\",\"req\":\"^1\"},{\"features\":[\"local-offset\"],\"name\":\"time\",\"req\":\"^0.3.15\"},{\"default_features\":false,\"name\":\"tiny_http\",\"req\":\"^0.12.0\"},{\"name\":\"url\",\"req\":\"^2\"}],\"features\":{\"default\":[\"gzip\",\"brotli\"],\"gzip\":[\"deflate\"],\"rustls\":[\"tiny_http/ssl-rustls\"],\"ssl\":[\"tiny_http/ssl\"]}}", "rtc-datachannel_0.20.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.12.0\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.45\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.11\"},{\"name\":\"log\",\"req\":\"^0.4.33\"},{\"name\":\"sansio\",\"req\":\"^1\"},{\"name\":\"sctp\",\"package\":\"rtc-sctp\",\"req\":\"^0.20.3\"},{\"default_features\":false,\"features\":[\"marshal\"],\"name\":\"shared\",\"package\":\"rtc-shared\",\"req\":\"^0.20.3\"}],\"features\":{}}", "rtc-dtls_0.20.3": "{\"dependencies\":[{\"name\":\"aes\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.80\"},{\"default_features\":false,\"features\":[\"aws-lc-sys\"],\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.17.3\"},{\"name\":\"bytecheck\",\"req\":\"^0.8\"},{\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"name\":\"bytes\",\"req\":\"^1.12.0\"},{\"features\":[\"block-padding\",\"alloc\"],\"name\":\"cbc\",\"req\":\"^0.1.2\"},{\"name\":\"ccm\",\"req\":\"^0.5.0\"},{\"name\":\"chacha20poly1305\",\"req\":\"^0.10.1\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.45\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.6.1\"},{\"kind\":\"dev\",\"name\":\"core_affinity\",\"req\":\"^0.8.1\"},{\"kind\":\"dev\",\"name\":\"ctrlc\",\"req\":\"^3.5.2\"},{\"name\":\"der-parser\",\"req\":\"^9.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.11\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"local-sync\",\"req\":\"^0.1.1\"},{\"name\":\"log\",\"req\":\"^0.4.33\"},{\"features\":[\"default\",\"ecdh\",\"ecdsa\"],\"name\":\"p256\",\"req\":\"^0.13.2\"},{\"name\":\"p384\",\"req\":\"^0.13.0\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3.0.3\"},{\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"features\":[\"pem\"],\"name\":\"rcgen\",\"req\":\"^0.14.8\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.14\"},{\"name\":\"rkyv\",\"req\":\"^0.8.17\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"features\":[\"std\"],\"name\":\"sec1\",\"req\":\"^0.7\"},{\"name\":\"sha1\",\"req\":\"^0.10.6\"},{\"name\":\"sha2\",\"req\":\"^0.10.8\"},{\"default_features\":false,\"features\":[\"crypto\",\"replay\"],\"name\":\"shared\",\"package\":\"rtc-shared\",\"req\":\"^0.20.3\"},{\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"features\":[\"static_secrets\"],\"name\":\"x25519-dalek\",\"req\":\"^2.0.1\"},{\"name\":\"x509-parser\",\"req\":\"^0.16.0\"}],\"features\":{\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"rustls/aws-lc-rs\",\"rcgen/aws_lc_rs\"],\"default\":[\"ring\"],\"pem\":[\"dep:pem\"],\"ring\":[\"dep:ring\",\"rustls/ring\",\"rcgen/ring\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a26f2fd61e6f..ea897239537d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4432,6 +4432,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-path", "codex-utils-process", + "memchr", "pretty_assertions", "regex", "serde", @@ -11828,13 +11829,13 @@ dependencies = [ [[package]] name = "process-wrap" -version = "9.0.1" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1395947e69c07400ef4d43db0051d6f773c21f647ad8b97382fc01f0204c60" +checksum = "0e3f4237d0e4741eb50bc5584db701f1299c85fa31ff0274dd6445e79dc42d12" dependencies = [ "futures", "indexmap 2.14.0", - "nix 0.30.1", + "nix 0.31.3", "tokio", "tracing", "windows 0.62.2", @@ -13027,9 +13028,8 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" +version = "3.3.0" +source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df" dependencies = [ "async-trait", "base64 0.23.0", @@ -13063,9 +13063,8 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" +version = "3.3.0" +source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df" dependencies = [ "darling 0.24.0", "proc-macro2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0e01c4534be3..fc1becc762f6 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -446,7 +446,8 @@ regex-automata = { version = "0.4.13", default-features = false, features = ["st regex-lite = "0.1.8" regex-syntax = "0.8.8" reqwest = { version = "0.12", features = ["cookies"] } -rmcp = { version = "=3.2.0", default-features = false } +# rmcp-v3.3.0 includes enterprise authorization helpers (rust-sdk#1234). +rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "3e636cab26c013eca5131103c03d20237f12c4df", version = "=3.3.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustix = { version = "1.1.4", features = ["net"] } rustls = { version = "0.23.45", default-features = false, features = [ diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index b4fdf1066b5e..79f8a28697d4 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -1075,3 +1075,111 @@ fn track_event_request_batches_only_isolates_accepted_line_fingerprint_events() assert!(batches[1][0].should_send_in_isolated_request()); assert!(batches[2][0].should_send_in_isolated_request()); } + +#[cfg(debug_assertions)] +#[tokio::test] +async fn account_switch_between_batches_does_not_send_old_credentials() { + use codex_http_client::DestinationPolicy; + use codex_http_client::NetworkPolicyController; + use codex_login::AuthCredentialsStoreMode as Store; + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + let home = unique_capture_path("batch-account-switch"); + fs::create_dir_all(&home).unwrap(); + let mut stored: codex_login::AuthDotJson = serde_json::from_value(serde_json::json!({ + "auth_mode": "chatgptAuthTokens", + "tokens": { + "id_token": "e30.e30.sig", + "access_token": "test-access-token-a", + "refresh_token": "", + "account_id": "account-a", + }, + })) + .unwrap(); + stored.last_refresh = Some(SystemTime::now().into()); + codex_login::save_auth(&home, &stored, Store::File, Default::default()).unwrap(); + let controller = NetworkPolicyController::default(); + let policy = controller.policy(); + let manager = Arc::new( + codex_login::AuthManager::new( + home.clone(), + /*enable_codex_api_key_env*/ false, + Store::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + Default::default(), + codex_login::AuthRouteConfig::from_http_client_factory( + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault) + .with_network_policy(policy.clone()), + ), + ) + .await, + ); + assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let destination = AnalyticsEventsDestination::Http { + url: format!("http://{}/events", listener.local_addr().unwrap()), + }; + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let server_manager = Arc::clone(&manager); + let server_home = home.clone(); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0; 4096]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_lowercase(); + let length: usize = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse() + .unwrap(); + if request.len() >= end + 4 + length { + break; + } + } + } + let first = { + let mut requests = captured.lock().unwrap(); + requests.push(String::from_utf8(request).unwrap()); + requests.len() == 1 + }; + if first { + let tokens = stored.tokens.as_mut().unwrap(); + tokens.account_id = Some("account-b".to_string()); + tokens.access_token = "test-access-token-b".to_string(); + codex_login::save_auth(&server_home, &stored, Store::File, Default::default()) + .unwrap(); + server_manager.reload().await; + assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); + } + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await; + } + }); + let events = vec![ + sample_accepted_line_fingerprint_event("one"), + sample_accepted_line_fingerprint_event("two"), + ]; + send_track_events(&manager, &destination, events, &ThreadProducts::default()).await; + server.abort(); + let _ = server.await; + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!( + requests[0] + .to_lowercase() + .contains("chatgpt-account-id: account-a") + ); + assert!(requests[0].contains("test-access-token-a")); + fs::remove_dir_all(home).unwrap(); +} diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4ae27cf806ba..3c65020a322a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -5474,6 +5474,67 @@ ], "type": "object" }, + "ThreadPredictionResult": { + "oneOf": [ + { + "properties": { + "text": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "completed" + ], + "title": "CompletedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompletedThreadPredictionResult", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "failed" + ], + "title": "FailedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FailedThreadPredictionResult", + "type": "object" + } + ] + }, + "ThreadPredictionUpdatedNotification": { + "properties": { + "result": { + "$ref": "#/definitions/ThreadPredictionResult" + }, + "sourceTurnId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "result", + "sourceTurnId", + "threadId" + ], + "type": "object" + }, "ThreadProjectUpdatedNotification": { "properties": { "projectId": { @@ -7153,6 +7214,26 @@ "title": "Thread/goal/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/prediction/updated" + ], + "title": "Thread/prediction/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadPredictionUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/prediction/updatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index fb7684cabf94..663cc473da03 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4839,6 +4839,26 @@ "title": "Thread/goal/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/prediction/updated" + ], + "title": "Thread/prediction/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadPredictionUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/prediction/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -23019,6 +23039,69 @@ "title": "ThreadNameUpdatedNotification", "type": "object" }, + "ThreadPredictionResult": { + "oneOf": [ + { + "properties": { + "text": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "completed" + ], + "title": "CompletedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompletedThreadPredictionResult", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "failed" + ], + "title": "FailedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FailedThreadPredictionResult", + "type": "object" + } + ] + }, + "ThreadPredictionUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "result": { + "$ref": "#/definitions/v2/ThreadPredictionResult" + }, + "sourceTurnId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "result", + "sourceTurnId", + "threadId" + ], + "title": "ThreadPredictionUpdatedNotification", + "type": "object" + }, "ThreadProjectUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index acdc575506b7..8856accc957b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -16281,6 +16281,26 @@ "title": "Thread/goal/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/prediction/updated" + ], + "title": "Thread/prediction/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadPredictionUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/prediction/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -20733,6 +20753,69 @@ "title": "ThreadNameUpdatedNotification", "type": "object" }, + "ThreadPredictionResult": { + "oneOf": [ + { + "properties": { + "text": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "completed" + ], + "title": "CompletedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompletedThreadPredictionResult", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "failed" + ], + "title": "FailedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FailedThreadPredictionResult", + "type": "object" + } + ] + }, + "ThreadPredictionUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "result": { + "$ref": "#/definitions/ThreadPredictionResult" + }, + "sourceTurnId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "result", + "sourceTurnId", + "threadId" + ], + "title": "ThreadPredictionUpdatedNotification", + "type": "object" + }, "ThreadProjectUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadPredictionUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadPredictionUpdatedNotification.json new file mode 100644 index 000000000000..9c644fd5adaf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadPredictionUpdatedNotification.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadPredictionResult": { + "oneOf": [ + { + "properties": { + "text": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "completed" + ], + "title": "CompletedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompletedThreadPredictionResult", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "failed" + ], + "title": "FailedThreadPredictionResultType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FailedThreadPredictionResult", + "type": "object" + } + ] + } + }, + "properties": { + "result": { + "$ref": "#/definitions/ThreadPredictionResult" + }, + "sourceTurnId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "result", + "sourceTurnId", + "threadId" + ], + "title": "ThreadPredictionUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 6aa5e5bfed28..2c53aa178308 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 72fe29bf62f8..1efda781c923 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 360f97186269..50cc33acf3c5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -57,6 +57,7 @@ import type { ThreadDeletedNotification } from "./v2/ThreadDeletedNotification"; import type { ThreadGoalClearedNotification } from "./v2/ThreadGoalClearedNotification"; import type { ThreadGoalUpdatedNotification } from "./v2/ThreadGoalUpdatedNotification"; import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification"; +import type { ThreadPredictionUpdatedNotification } from "./v2/ThreadPredictionUpdatedNotification"; import type { ThreadProjectUpdatedNotification } from "./v2/ThreadProjectUpdatedNotification"; import type { ThreadQueueChangedNotification } from "./v2/ThreadQueueChangedNotification"; import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification"; @@ -88,4 +89,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/reverted", "params": ThreadRevertedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/attachment/updated", "params": ThreadAttachmentUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/queue/changed", "params": ThreadQueueChangedNotification } | { "method": "project/changed", "params": ProjectChangedNotification } | { "method": "thread/project/updated", "params": ThreadProjectUpdatedNotification } | { "method": "thread/environment/connected", "params": EnvironmentConnectionNotification } | { "method": "thread/environment/disconnected", "params": EnvironmentConnectionNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "autoApprovalReview/strictReviewRequired", "params": StrictReviewRequiredNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "rawResponse/completed", "params": RawResponseCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "mcpServer/event/stream/notification", "params": McpServerEventStreamNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/gatewayOAuth/changed", "params": GatewayOAuthChangedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "modelProvider/authRecoveryStarted", "params": AuthRecoveryNotification } | { "method": "modelProvider/authRecoveryCompleted", "params": AuthRecoveryNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/item/started", "params": ThreadRealtimeItemStartedNotification } | { "method": "thread/realtime/item/transcript/delta", "params": ThreadRealtimeItemTranscriptDeltaNotification } | { "method": "thread/realtime/item/completed", "params": ThreadRealtimeItemCompletedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/reverted", "params": ThreadRevertedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/attachment/updated", "params": ThreadAttachmentUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/prediction/updated", "params": ThreadPredictionUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/queue/changed", "params": ThreadQueueChangedNotification } | { "method": "project/changed", "params": ProjectChangedNotification } | { "method": "thread/project/updated", "params": ThreadProjectUpdatedNotification } | { "method": "thread/environment/connected", "params": EnvironmentConnectionNotification } | { "method": "thread/environment/disconnected", "params": EnvironmentConnectionNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "autoApprovalReview/strictReviewRequired", "params": StrictReviewRequiredNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "rawResponse/completed", "params": RawResponseCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "mcpServer/event/stream/notification", "params": McpServerEventStreamNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/gatewayOAuth/changed", "params": GatewayOAuthChangedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "modelProvider/authRecoveryStarted", "params": AuthRecoveryNotification } | { "method": "modelProvider/authRecoveryCompleted", "params": AuthRecoveryNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/item/started", "params": ThreadRealtimeItemStartedNotification } | { "method": "thread/realtime/item/transcript/delta", "params": ThreadRealtimeItemTranscriptDeltaNotification } | { "method": "thread/realtime/item/completed", "params": ThreadRealtimeItemCompletedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotificationEnvelope.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotificationEnvelope.ts index 3c306f005ffc..f3c6d2c2e6a7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotificationEnvelope.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotificationEnvelope.ts @@ -57,6 +57,7 @@ import type { ThreadDeletedNotification } from "./v2/ThreadDeletedNotification"; import type { ThreadGoalClearedNotification } from "./v2/ThreadGoalClearedNotification"; import type { ThreadGoalUpdatedNotification } from "./v2/ThreadGoalUpdatedNotification"; import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification"; +import type { ThreadPredictionUpdatedNotification } from "./v2/ThreadPredictionUpdatedNotification"; import type { ThreadProjectUpdatedNotification } from "./v2/ThreadProjectUpdatedNotification"; import type { ThreadQueueChangedNotification } from "./v2/ThreadQueueChangedNotification"; import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification"; @@ -98,4 +99,4 @@ export type ServerNotificationEnvelope = { * Optional so clients can decode notifications from older app-server * versions. Current app-server versions always populate it. */ -emittedAtMs?: number, } & ({ "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/reverted", "params": ThreadRevertedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/attachment/updated", "params": ThreadAttachmentUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/queue/changed", "params": ThreadQueueChangedNotification } | { "method": "project/changed", "params": ProjectChangedNotification } | { "method": "thread/project/updated", "params": ThreadProjectUpdatedNotification } | { "method": "thread/environment/connected", "params": EnvironmentConnectionNotification } | { "method": "thread/environment/disconnected", "params": EnvironmentConnectionNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "autoApprovalReview/strictReviewRequired", "params": StrictReviewRequiredNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "rawResponse/completed", "params": RawResponseCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "mcpServer/event/stream/notification", "params": McpServerEventStreamNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/gatewayOAuth/changed", "params": GatewayOAuthChangedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "modelProvider/authRecoveryStarted", "params": AuthRecoveryNotification } | { "method": "modelProvider/authRecoveryCompleted", "params": AuthRecoveryNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/item/started", "params": ThreadRealtimeItemStartedNotification } | { "method": "thread/realtime/item/transcript/delta", "params": ThreadRealtimeItemTranscriptDeltaNotification } | { "method": "thread/realtime/item/completed", "params": ThreadRealtimeItemCompletedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }); +emittedAtMs?: number, } & ({ "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/reverted", "params": ThreadRevertedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/attachment/updated", "params": ThreadAttachmentUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/prediction/updated", "params": ThreadPredictionUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/queue/changed", "params": ThreadQueueChangedNotification } | { "method": "project/changed", "params": ProjectChangedNotification } | { "method": "thread/project/updated", "params": ThreadProjectUpdatedNotification } | { "method": "thread/environment/connected", "params": EnvironmentConnectionNotification } | { "method": "thread/environment/disconnected", "params": EnvironmentConnectionNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "autoApprovalReview/strictReviewRequired", "params": StrictReviewRequiredNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "rawResponse/completed", "params": RawResponseCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "mcpServer/event/stream/notification", "params": McpServerEventStreamNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/gatewayOAuth/changed", "params": GatewayOAuthChangedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "modelProvider/authRecoveryStarted", "params": AuthRecoveryNotification } | { "method": "modelProvider/authRecoveryCompleted", "params": AuthRecoveryNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/item/started", "params": ThreadRealtimeItemStartedNotification } | { "method": "thread/realtime/item/transcript/delta", "params": ThreadRealtimeItemTranscriptDeltaNotification } | { "method": "thread/realtime/item/completed", "params": ThreadRealtimeItemCompletedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadPredictionResult.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadPredictionResult.ts new file mode 100644 index 000000000000..0b9678f128ba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadPredictionResult.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadPredictionResult = { "type": "completed", text: string | null, } | { "type": "failed" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadPredictionUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadPredictionUpdatedNotification.ts new file mode 100644 index 000000000000..8a33a5089f66 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadPredictionUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadPredictionResult } from "./ThreadPredictionResult"; + +export type ThreadPredictionUpdatedNotification = { threadId: string, sourceTurnId: string, result: ThreadPredictionResult, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d0c9fea6d0be..b96f29ce44bb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -526,6 +526,8 @@ export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoU export type { ThreadMetadataUpdateParams } from "./ThreadMetadataUpdateParams"; export type { ThreadMetadataUpdateResponse } from "./ThreadMetadataUpdateResponse"; export type { ThreadNameUpdatedNotification } from "./ThreadNameUpdatedNotification"; +export type { ThreadPredictionResult } from "./ThreadPredictionResult"; +export type { ThreadPredictionUpdatedNotification } from "./ThreadPredictionUpdatedNotification"; export type { ThreadProjectUpdatedNotification } from "./ThreadProjectUpdatedNotification"; export type { ThreadQueueChangedNotification } from "./ThreadQueueChangedNotification"; export type { ThreadReadParams } from "./ThreadReadParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c37c4956f1c3..c6d6fad791b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -605,6 +605,12 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadSetNameResponse, }, + #[experimental("thread/prediction/request")] + ThreadPredictionRequest => "thread/prediction/request" { + params: v2::ThreadPredictionRequestParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadPredictionRequestResponse, + }, ThreadGoalSet => "thread/goal/set" { params: v2::ThreadGoalSetParams, serialization: thread_id(params.thread_id), @@ -1927,6 +1933,8 @@ server_notification_definitions! { ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), ThreadAttachmentUpdated => "thread/attachment/updated" (v2::ThreadAttachmentUpdatedNotification), ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), + #[experimental("thread/prediction/updated")] + ThreadPredictionUpdated => "thread/prediction/updated" (v2::ThreadPredictionUpdatedNotification), ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), #[experimental("thread/queue/changed")] ThreadQueueChanged => "thread/queue/changed" (v2::ThreadQueueChangedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 5bc7d96f0071..1c1432dfbde1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -34,6 +34,7 @@ mod rollout; mod thread; mod thread_attachment; mod thread_data; +mod thread_prediction; mod thread_usage; mod turn; mod user_verification; @@ -74,6 +75,7 @@ pub use shared::*; pub use thread::*; pub use thread_attachment::*; pub use thread_data::*; +pub use thread_prediction::*; pub use thread_usage::*; pub use turn::*; pub use user_verification::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_prediction.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_prediction.rs new file mode 100644 index 000000000000..6fc5c495af21 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_prediction.rs @@ -0,0 +1,35 @@ +//! App-server API types for predictions. + +use crate::JsonSchema; +use crate::TS; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadPredictionRequestParams { + pub thread_id: String, + pub source_turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub struct ThreadPredictionRequestResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadPredictionUpdatedNotification { + pub thread_id: String, + pub source_turn_id: String, + pub result: ThreadPredictionResult, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadPredictionResult { + Completed { text: Option }, + Failed, +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f5a9a65425ae..796ecf2fc86a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -11,6 +11,7 @@ use crate::current_time::app_server_time_provider; use crate::error_code::internal_error; use crate::error_code::invalid_params; use crate::error_code::invalid_request; +use crate::error_code::method_not_found; use crate::extensions::ThreadExtensionDependencies; use crate::extensions::app_server_extension_event_sink; use crate::extensions::thread_extensions; @@ -1443,6 +1444,9 @@ impl MessageProcessor { .thread_unarchive(request_id.clone(), params) .await } + ClientRequest::ThreadPredictionRequest { .. } => Err(method_not_found( + "thread/prediction/request is not implemented yet", + )), ClientRequest::ThreadCompactStart { params, .. } => { self.thread_processor .thread_compact_start(&request_id, params) diff --git a/codex-rs/app-server/src/notification_media.rs b/codex-rs/app-server/src/notification_media.rs index a3bb780b36dd..4223ea796d28 100644 --- a/codex-rs/app-server/src/notification_media.rs +++ b/codex-rs/app-server/src/notification_media.rs @@ -66,6 +66,7 @@ pub(crate) fn without_notification_media(notification: ServerNotification) -> Se | ServerNotification::ThreadNameUpdated(_) | ServerNotification::ThreadAttachmentUpdated(_) | ServerNotification::ThreadGoalUpdated(_) + | ServerNotification::ThreadPredictionUpdated(_) | ServerNotification::ThreadGoalCleared(_) | ServerNotification::ThreadQueueChanged(_) | ServerNotification::ProjectChanged(_) diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 56c4838ef670..9c4cd1be080d 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -74,6 +74,56 @@ async fn map_api_error_preserves_retry_delay() { } } +/// Mapping a new error category preserves elapsed advice without making hard failures retryable. +#[tokio::test(start_paused = true)] +async fn http_retry_deadline_survives_mapping_and_respects_terminal_errors() { + use std::time::Duration; + + let advice = RetryAfter::from_delay(Duration::from_secs(10)).expect("retry advice"); + tokio::time::advance(Duration::from_secs(4)).await; + for (status, code, retryable) in [ + (503, "server_is_overloaded", true), + (429, "rate_limit_exceeded", true), + (429, "insufficient_quota", false), + (429, "usage_limit_reached", false), + (400, "cyber_policy", false), + ] { + let error = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::from_u16(status).unwrap(), + url: None, + headers: None, + body: Some(serde_json::json!({"error": {"type": code, "code": code, "message": "sensitive upstream detail"}}).to_string()), + retry_after: Some(advice), + })); + assert_eq!( + (error.retry_after(), error.server_retry_delay()), + (Some(advice), Some(Duration::from_secs(6))), + "{code}", + ); + assert_eq!( + error.retry_delay(/*retry_count*/ 1), + retryable.then_some(Duration::from_secs(6)), + "{code}", + ); + if status == 429 && retryable { + assert_eq!( + ( + error.to_codex_protocol_error(), + error.http_status_code_value(), + error.to_string() + ), + ( + CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + }, + Some(429), + "exceeded retry limit, last status: 429 Too Many Requests".to_string(), + ), + ); + } + } +} + #[test] fn map_api_error_distinguishes_capacity_from_slow_down() { for (code, expected, retryable) in [ diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 7f3ebd7aac30..b4463500b997 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -14,6 +14,7 @@ use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; use codex_client::TransportError; use codex_http_client::HttpClientFactory; +use codex_http_client::RetryAfter; use codex_websocket_client::WebSocketConnection; use codex_websocket_client::WebSocketConnector; use futures::FutureExt; @@ -570,6 +571,7 @@ fn map_ws_error(err: WsError, url: &Url) -> ApiError { WsError::Http(response) => { let status = response.status(); let headers = response.headers().clone(); + let retry_after = RetryAfter::from_headers(&headers); let body = response .body() .as_ref() @@ -579,7 +581,7 @@ fn map_ws_error(err: WsError, url: &Url) -> ApiError { url: Some(url.to_string()), headers: Some(headers), body, - retry_after: None, + retry_after, }) } WsError::ConnectionClosed | WsError::AlreadyClosed => { diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 674b38770257..d84096785210 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -449,6 +449,8 @@ pub struct TuiAgentsKeymap { pub new_task: Option, /// Open a new session in a worktree from the project default branch. pub new_worktree: Option, + /// Fork the selected conversation and open the new session. + pub fork: Option, /// Rename the selected task. pub rename: Option, /// Stop the selected running task. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6aaa39c3f540..bcc4a8ecceb2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4435,6 +4435,7 @@ "agents": { "archive": null, "delete": null, + "fork": null, "hide": null, "new_task": null, "new_worktree": null, @@ -4789,6 +4790,14 @@ ], "description": "Permanently delete the selected task and its child agents after confirmation." }, + "fork": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Fork the selected conversation and open the new session." + }, "hide": { "allOf": [ { @@ -5373,6 +5382,7 @@ "default": { "archive": null, "delete": null, + "fork": null, "hide": null, "new_task": null, "new_worktree": null, diff --git a/codex-rs/core/src/responses_retry.rs b/codex-rs/core/src/responses_retry.rs index c8edc8b9d4fb..048a68bfc9bc 100644 --- a/codex-rs/core/src/responses_retry.rs +++ b/codex-rs/core/src/responses_retry.rs @@ -1,5 +1,6 @@ //! Shared retry and transport fallback decisions for Responses requests. //! Content-filter guidance is recorded for sampling requests before retry decisions. +//! Server advice controls timing without extending configured retry limits. use std::time::Duration; @@ -116,13 +117,16 @@ pub(crate) async fn handle_response_stream_error( return Ok(()); } - // TODO(anp): Respect server retry advice before issuing the fallback HTTP request. if retry_state.retries >= max_retries && client_session.try_switch_fallback_transport( &turn_context.session_telemetry, turn_context.model_info(), ) { + // Changing transport must not bypass the server's retry deadline. + if let Some(retry_after) = retry_after { + tokio::time::sleep_until(retry_after.deadline()).await; + } sess.send_event( turn_context, EventMsg::Warning(WarningEvent { diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index c253383610fa..da0fbbea1f70 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,5 +1,7 @@ use codex_exec_server::ShellInfo; use codex_shell_command::shell_detect::DetectedShell; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; @@ -91,6 +93,61 @@ impl ShellInvocation { ShellType::Bash | ShellType::Zsh | ShellType::Sh ) } + + /// Builds launch arguments that try to restore the executor's directories to `PATH` + /// after login startup, so `command` and its children can find them. + /// + /// Call this for the POSIX login shell started by Codex when the executor reports + /// directories and the user has not explicitly configured `PATH`. The request's login + /// mode is unchanged, even if startup makes `PATH` read-only. Returns `None` if no + /// directories are reported or the shell or any path cannot use this setup; keep the + /// original arguments in that case. + pub(crate) fn derive_exec_args_with_path_prepends( + &self, + command: &str, + paths: &[PathUri], + ) -> Option> { + if !self.is_posix_login() || paths.is_empty() { + return None; + } + let mut setup = Vec::with_capacity(paths.len()); + // Prepend backwards so newly added directories retain the executor's order. + for path in paths.iter().rev() { + if path.infer_path_convention() != Some(PathConvention::Posix) { + return None; + } + let path = path.inferred_native_path_string(); + // POSIX PATH has no way to escape a colon inside a single directory. + if path.contains(':') { + return None; + } + let path = shlex::try_quote(&path).ok()?; + // Padding `${PATH-}` with colons makes `case` match whole entries, even at the ends; + // `-` also handles an unset PATH. Re-export a match so child commands inherit it; + // otherwise probe in a subshell: assigning a read-only PATH can exit the login shell + // before the user's command runs. `${PATH:+...}` adds `:` only when PATH is nonempty, + // so we don't make the shell search the current directory. + setup.push(format!( + "case \":${{PATH-}}:\" in *:{path}:*) export PATH ;; \ + *) if (export PATH=) 2>/dev/null; then \ + export PATH={path}${{PATH:+:\"$PATH\"}}; fi ;; esac" + )); + } + let setup = setup.join("; "); + // Keep setup on the first input line so shell diagnostics and $LINENO still refer to + // the original script. If the quoted path contains literal newlines, decode only the + // setup with eval in the current shell; the trailing `esac` keeps command substitution + // from trimming any newlines in the path. + let setup = if setup.contains('\n') { + let escaped = setup.replace('\\', "\\\\").replace('\n', "\\n"); + let escaped = shlex::try_quote(&escaped).ok()?; + format!("eval \"$(command -p printf '%b' {escaped})\"") + } else { + setup + }; + let command = format!("{setup}; {command}"); + Some(self.shell.derive_exec_args(&command, self.use_login_shell)) + } } #[cfg(all(test, unix))] diff --git a/codex-rs/core/src/shell_tests.rs b/codex-rs/core/src/shell_tests.rs index 0963ef83645f..1077aba2b656 100644 --- a/codex-rs/core/src/shell_tests.rs +++ b/codex-rs/core/src/shell_tests.rs @@ -1,4 +1,5 @@ use super::*; +use pretty_assertions::assert_eq; use std::path::PathBuf; use std::process::Command; @@ -143,6 +144,68 @@ fn derive_exec_args() { ); } +/// Diagnostics and `$LINENO` must refer to the original script, even with newlines in the package path. +#[test] +fn codex_path_setup_preserves_shell_script_line_numbers() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let run = |argv: &[String]| { + Command::new(&argv[0]) + .args(&argv[1..]) + .env("ZDOTDIR", temp_dir.path()) + .env("HOME", temp_dir.path()) + .env_remove("BASH_ENV") + .output() + }; + for shell_type in [ShellType::Bash, ShellType::Zsh, ShellType::Sh] { + let Some(shell) = get_shell(shell_type) else { + assert_eq!(shell_type, ShellType::Zsh, "only zsh may be unavailable"); + continue; + }; + let invocation = ShellInvocation { + shell, + use_login_shell: true, + }; + for directory in [ + "codex-path", + "codex '\\c[*]$(printf bad)`printf bad`\npath\n", + ] { + let directory = temp_dir.path().join(directory); + let path = PathUri::from_host_native_path(&directory)?; + let commands = [ + ":\nif then", + "printf '%s\\n' \"$LINENO\"\nprintf '%s\\n' \"$LINENO\"", + ]; + for command in commands { + if command.contains("LINENO") && shell_type == ShellType::Sh { + continue; // dash does not implement LINENO. + } + let original = invocation + .shell + .derive_exec_args(command, /*use_login_shell*/ true); + let wrapped = invocation + .derive_exec_args_with_path_prepends(command, std::slice::from_ref(&path)) + .expect("a POSIX package path should be usable"); + assert_eq!( + run(&wrapped)?, + run(&original)?, + "{shell_type:?}: {directory:?}" + ); + assert_eq!(wrapped[2].lines().count(), command.lines().count()); + } + + let command = "printf '%s' \"$PATH\""; + let wrapped = invocation + .derive_exec_args_with_path_prepends(command, std::slice::from_ref(&path)) + .expect("a POSIX package path should be usable"); + let output = run(&wrapped)?; + assert!(output.status.success()); + let actual_path = String::from_utf8(output.stdout)?; + assert_eq!(actual_path.split(':').next(), directory.to_str()); + } + } + Ok(()) +} + #[tokio::test] async fn test_current_shell_detects_zsh() { let shell = Command::new("sh") diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 4666142887a7..5ace22c811bc 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -537,11 +537,9 @@ impl Session { pub async fn abort_all_tasks(self: &Arc, reason: TurnAbortReason) { let mut aborted_turn = false; let mut active_turn_to_clear = None; - let mut turn_context = None; if let Some(mut active_turn) = self.take_active_turn(&reason).await { let task = active_turn.task.take(); aborted_turn = task.is_some(); - turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context)); if let Some(task) = task { self.handle_task_abort( task, @@ -556,10 +554,6 @@ impl Session { } } - if let Some(turn_context) = turn_context.as_deref() { - self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) - .await; - } if let Some(active_turn) = active_turn_to_clear { // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. @@ -609,15 +603,10 @@ impl Session { error: Option, ) { let task = active_turn.task.take(); - let turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context)); if let Some(task) = task { self.handle_task_abort(task, reason.clone(), &active_turn.turn_state, error) .await; } - if let Some(turn_context) = turn_context.as_deref() { - self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) - .await; - } // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. self.input_queue.clear_pending(&active_turn).await; @@ -928,6 +917,8 @@ impl Session { ) { let sub_id = task.turn_context.sub_id.clone(); if task.cancellation_token.is_cancelled() { + self.emit_turn_abort_lifecycle(reason, task.turn_context.extension_data.as_ref()) + .await; return; } @@ -1005,6 +996,8 @@ impl Session { turn_id: task.turn_context.sub_id.clone(), profile, }); + self.emit_turn_abort_lifecycle(reason.clone(), task.turn_context.extension_data.as_ref()) + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 4657b64fbcb1..98b879583911 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -7,6 +7,7 @@ use codex_tools::UnifiedExecShellMode; use codex_tools::ZshForkConfig; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -95,6 +96,182 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { Ok(()) } +/// Nested commands must find the first reported tool directory after login startup without duplicating existing entries. +#[cfg(unix)] +#[test] +fn test_get_command_keeps_path_prepends_after_login_startup() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempfile::tempdir()?; + let path_dir = temp_dir.path().join("codex '[*] path"); + let extra_path_dir = temp_dir.path().join("extra tools"); + for directory in [&path_dir, &extra_path_dir] { + std::fs::create_dir(directory)?; + let rg = directory.join("rg"); + std::fs::write(&rg, "#!/bin/sh\n")?; + std::fs::set_permissions(&rg, std::fs::Permissions::from_mode(0o755))?; + } + let rg = path_dir.join("rg"); + let startup = temp_dir.path().join("startup"); + std::fs::write( + &startup, + "export PATH=\"$CODEX_TEST_LOGIN_PATH\" CODEX_TEST_LOGIN_PATH_RESET=1\n", + )?; + let path_dirs = [ + PathUri::from_host_native_path(&path_dir)?, + PathUri::from_host_native_path(&extra_path_dir)?, + ]; + let args: ExecCommandArgs = parse_arguments( + r#"{"cmd":"sh -c 'test \"$CODEX_TEST_LOGIN_PATH_RESET\" = 1 && printf \"%s\\n\" \"$PATH\" && command -v rg'"}"#, + )?; + let shell = Arc::new(get_shell(ShellType::Bash).expect("bash should be available")); + let resolved = get_command( + &args, + Arc::clone(&shell), + &UnifiedExecShellMode::Direct, + /*allow_login_shell*/ true, + ) + .map_err(anyhow::Error::msg)?; + assert_eq!( + resolved.command, + shell.derive_exec_args(&args.cmd, /*use_login_shell*/ true) + ); + + let execution_command = resolved + .shell + .derive_exec_args_with_path_prepends(&args.cmd, &path_dirs) + .expect("login shell should restore PATH"); + let path_dir = path_dirs[0].inferred_native_path_string(); + let extra_path_dir = path_dirs[1].inferred_native_path_string(); + let already_present = format!( + "{}:{path_dir}:{extra_path_dir}:/usr/bin:/bin", + temp_dir.path().display() + ); + for (login_path, expected_path) in [ + ( + "/usr/bin:/bin".to_string(), + format!("{path_dir}:{extra_path_dir}:/usr/bin:/bin"), + ), + (already_present.clone(), already_present), + ] { + let output = std::process::Command::new(&execution_command[0]) + .args(&execution_command[1..]) + .env("BASH_ENV", &startup) + .env("CODEX_TEST_LOGIN_PATH", login_path) + .env_remove("CODEX_TEST_LOGIN_PATH_RESET") + .env("PATH", &path_dir) + .output()?; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout)?, + format!("{expected_path}\n{}\n", rg.display()) + ); + } + Ok(()) +} + +/// The requested login shell and its command still run when startup forbids changing PATH. +#[cfg(unix)] +#[test] +fn test_get_command_runs_login_shell_when_path_is_readonly() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let startup = temp_dir.path().join("startup"); + // An explicit exit from a login Bash runs ~/.bash_logout; keep the runner's file out of the test. + std::fs::write(temp_dir.path().join(".bash_logout"), ":\n")?; + std::fs::write( + &startup, + "if shopt -q login_shell; then\n export HOME=\"$CODEX_TEST_LOGIN_HOME\"\n export PATH=/usr/bin:/bin\n readonly PATH\n set -e\nfi\n", + )?; + let path_dir = PathUri::from_host_native_path(temp_dir.path().join("codex-path"))?; + let args: ExecCommandArgs = parse_arguments( + r#"{"cmd":"shopt -q login_shell || exit 90; case $- in *e*) ;; *) exit 91 ;; esac; /bin/sh -c 'printf \"%s\\n\" \"$PATH\"'; exit 23","login":true}"#, + )?; + let shell = Arc::new(get_shell(ShellType::Bash).expect("bash should be available")); + let resolved = get_command( + &args, + shell, + &UnifiedExecShellMode::Direct, + /*allow_login_shell*/ true, + ) + .map_err(anyhow::Error::msg)?; + let command = resolved + .shell + .derive_exec_args_with_path_prepends(&args.cmd, &[path_dir]) + .expect("the reported directory can be put on PATH"); + let output = std::process::Command::new(&command[0]) + .args(&command[1..]) + .env("BASH_ENV", startup) + .env("CODEX_TEST_LOGIN_HOME", temp_dir.path()) + .env_remove("SHLVL") + .output()?; + + assert_eq!( + ( + output.status.code(), + String::from_utf8(output.stdout)?, + String::from_utf8(output.stderr)? + ), + (Some(23), "/usr/bin:/bin\n".to_string(), String::new()) + ); + Ok(()) +} + +/// The package path only affects Codex's own POSIX login shells when the executor reports a POSIX path. +#[test] +fn test_get_command_does_not_change_nested_login_or_powershell() -> anyhow::Result<()> { + for (shell_type, shell_path, arguments, use_login_shell, path) in [ + ( + ShellType::Bash, + "/bin/bash", + r#"{"cmd":"bash -lc 'command -v rg'","login":false}"#, + false, + "file:///executor/codex-path", + ), + ( + ShellType::PowerShell, + "pwsh", + r#"{"cmd":"Get-Command rg"}"#, + true, + "file:///executor/codex-path", + ), + ( + ShellType::Bash, + "/bin/bash", + r#"{"cmd":"command -v rg"}"#, + true, + "file:///C:/executor/codex-path", + ), + ] { + let args: ExecCommandArgs = parse_arguments(arguments)?; + let path_dir = PathUri::parse(path)?; + let shell = Arc::new(Shell { + shell_type, + shell_path: PathBuf::from(shell_path), + }); + let resolved = get_command( + &args, + Arc::clone(&shell), + &UnifiedExecShellMode::Direct, + /*allow_login_shell*/ true, + ) + .map_err(anyhow::Error::msg)?; + assert_eq!( + ( + resolved.command, + resolved + .shell + .derive_exec_args_with_path_prepends(&args.cmd, &[path_dir]) + ), + (shell.derive_exec_args(&args.cmd, use_login_shell), None) + ); + } + Ok(()) +} + #[test] fn test_get_command_resolves_powershell_by_type() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 7d88650f59d9..9ac7b76ebd05 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -13,7 +13,6 @@ use crate::memory_usage::shell_script_for_invocation; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolCallSource; use crate::tools::context::ToolCallState; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c6ef29c606e7..82a76ded4c24 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -45,6 +45,7 @@ use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_core_plugins::PluginMetricsSidecar; +use codex_features::Feature; use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; @@ -511,18 +512,31 @@ impl<'a> ToolRuntime for UnifiedExecRunt }; #[cfg(not(unix))] let runtime_path_prepends = RuntimePathPrepends::default(); - let mut command = if environment_is_remote { - base_command.to_vec() - } else { - maybe_wrap_shell_lc_with_snapshot( - base_command, + // Restore the executor's PATH directories inside the shell so nested commands can + // still find bundled tools even if login startup clears the inherited PATH. + // `base_command` already carries the requested login mode; keep it if setup is unavailable. + // `hook_command` is the raw script the helper needs to prepend setup inside that same shell. + let mut command = base_command.to_vec(); + if ctx.session.enabled(Feature::LoginShellPackagePath) + && req.shell.is_posix_login() + && !explicit_env_overrides.contains_key("PATH") + && let Ok(info) = req.turn_environment.environment.info().await + && let Some(command_with_path_prepends) = req + .shell + .derive_exec_args_with_path_prepends(&req.hook_command, &info.prepend_path_dirs) + { + command = command_with_path_prepends; + } + if !environment_is_remote { + command = maybe_wrap_shell_lc_with_snapshot( + &command, shell, shell_snapshot_location.as_ref(), &explicit_env_overrides, &env, &runtime_path_prepends, - ) - }; + ); + } let brokered_shell_snapshot_missing = !environment_is_remote && managed_network.is_some() && env diff --git a/codex-rs/core/tests/suite/abort_lifecycle.rs b/codex-rs/core/tests/suite/abort_lifecycle.rs new file mode 100644 index 000000000000..296094d2c178 --- /dev/null +++ b/codex-rs/core/tests/suite/abort_lifecycle.rs @@ -0,0 +1,150 @@ +//! Verifies abort callbacks finish exactly once before the terminal event is published. + +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; +use codex_extension_api::ExtensionFuture; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::TurnAbortInput; +use codex_extension_api::TurnLifecycleContributor; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use test_case::test_case; +use tokio::sync::Notify; + +#[derive(Default)] +struct AbortGate { + entered: Notify, + release: Notify, + completed: Mutex>, +} + +impl TurnLifecycleContributor for AbortGate { + fn on_turn_abort<'a>(&'a self, input: TurnAbortInput<'a>) -> ExtensionFuture<'a, ()> { + Box::pin(async move { + self.entered.notify_one(); + self.release.notified().await; + self.completed + .lock() + .expect("abort callback records") + .push((input.turn_store.level_id().to_string(), input.reason)); + }) + } +} + +#[derive(Clone, Copy)] +enum AbortOperation { + Interrupt, + ConditionalInterrupt, + Shutdown, +} + +#[test_case(AbortOperation::Interrupt; "interrupt")] +#[test_case(AbortOperation::ConditionalInterrupt; "conditional_interrupt")] +#[test_case(AbortOperation::Shutdown; "shutdown")] +#[tokio::test] +async fn abort_lifecycle_finishes_before_terminal_event( + operation: AbortOperation, +) -> anyhow::Result<()> { + let server = responses::start_mock_server().await; + let request = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("response"), + responses::ev_function_call("gate-call", "gate", "{}"), + responses::ev_completed("response"), + ]), + ) + .await; + let gate = Arc::new(AbortGate::default()); + let mut extensions = ExtensionRegistryBuilder::new(); + extensions.turn_lifecycle_contributor(gate.clone()); + let mut builder = test_codex().with_extensions(Arc::new(extensions.build())); + let test = builder.build_with_auto_env(&server).await?; + let thread = test + .thread_manager + .start_thread(StartThreadOptions { + environments: Some(vec![test.executor_environment().selection().clone()]), + dynamic_tools: vec![DynamicToolSpec::Function(DynamicToolFunctionSpec { + name: "gate".to_string(), + description: "Wait for the host.".to_string(), + input_schema: serde_json::json!({"type": "object", "properties": {}}), + defer_loading: false, + })], + ..StartThreadOptions::new(test.config.clone()) + }) + .await? + .thread; + thread + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Call gate.".to_string(), + text_elements: Vec::new(), + }])) + .await?; + let EventMsg::DynamicToolCallRequest(call) = wait_for_event(&thread, |event| { + matches!(event, EventMsg::DynamicToolCallRequest(_)) + }) + .await + else { + unreachable!(); + }; + request.single_request(); + + match operation { + AbortOperation::Interrupt => { + thread.submit(Op::Interrupt).await?; + } + AbortOperation::ConditionalInterrupt => { + let (reply, received) = tokio::sync::oneshot::channel(); + thread + .submit(Op::InterruptIfNoPendingInput { + turn_id: call.turn_id.clone(), + reply, + }) + .await?; + assert!(received.await?); + } + AbortOperation::Shutdown => { + thread.submit(Op::Shutdown).await?; + } + } + tokio::time::timeout(Duration::from_secs(10), gate.entered.notified()).await?; + // Keep the callback suspended: scheduling alone must not hide an early event. + let early_event = tokio::time::timeout( + Duration::from_millis(100), + wait_for_event(&thread, |event| matches!(event, EventMsg::TurnAborted(_))), + ) + .await; + gate.release.notify_one(); + assert!( + early_event.is_err(), + "TurnAborted arrived before its lifecycle callback finished" + ); + let EventMsg::TurnAborted(aborted) = + wait_for_event(&thread, |event| matches!(event, EventMsg::TurnAborted(_))).await + else { + unreachable!(); + }; + assert_eq!(aborted.turn_id, Some(call.turn_id.clone())); + assert_eq!(aborted.reason, TurnAbortReason::Interrupted); + if !matches!(operation, AbortOperation::Shutdown) { + thread.submit(Op::Shutdown).await?; + } + wait_for_event(&thread, |event| matches!(event, EventMsg::ShutdownComplete)).await; + assert_eq!( + *gate.completed.lock().expect("abort callback records"), + vec![(call.turn_id, TurnAbortReason::Interrupted)], + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 7455ff786f03..2dced3650df9 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -23,6 +23,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; @@ -3074,6 +3075,193 @@ async fn pre_sampling_compact_falls_back_after_previous_model_stream_retries_are ); } +/// Both compaction models keep the configured retry budget when the server supplies advice, +/// including the selected model's final attempt. +#[test_case::test_case(2; "selected_model_recovers")] +#[test_case::test_case(3; "selected_model_exhausts_retries")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_advised_errors_fall_back_then_retry_on_selected_model( + selected_model_failures: usize, +) { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.4"; + let selected_model = "gpt-5.2"; + let _models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_context_window(previous_model, /*context_window*/ 273_000), + model_info_with_context_window(selected_model, /*context_window*/ 125_000), + ], + }, + ) + .await; + let overload = wiremock::ResponseTemplate::new(/*s*/ 503) + .insert_header("Retry-After", "0") + .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })); + let mut responses = vec![ + sse_response(sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 120_000), + ])), + overload.clone(), // Old model: initial attempt and two retries. + overload.clone(), + overload.clone(), + ]; + responses.extend(vec![overload; selected_model_failures]); + if selected_model_failures < 3 { + responses.extend([ + sse_response(remote_v2_compaction_response()), + sse_response(sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ])), + ]); + } + let request_log = mount_response_sequence(&server, responses).await; + let mut model_provider = openai_model_provider(&server); + model_provider.request_max_retries = Some(0); + model_provider.stream_max_retries = Some(2); + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }) + .build_with_auto_env(&server) + .await + .expect("build codex"); + test.codex + .start_or_steer_turn(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.codex + .start_or_steer_turn(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + selected_model.to_string(), + )) + .await + .expect("submit selected-model turn"); + if selected_model_failures < 3 { + assert_compaction_uses_turn_lifecycle_id(&test.codex).await; + } else { + let mut errors = Vec::new(); + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Error(error) => errors.push(error.codex_error_info), + EventMsg::TurnComplete(event) => { + assert_eq!( + event.error.and_then(|error| error.codex_error_info), + Some(CodexErrorInfo::ServerOverloaded) + ); + break; + } + _ => {} + } + } + assert_eq!(errors, vec![Some(CodexErrorInfo::ServerOverloaded)]); + } + let actual_models = request_log + .requests() + .iter() + .map(|request| { + request.body_json()["model"] + .as_str() + .expect("request model") + .to_string() + }) + .collect::>(); + let mut expected_models = vec![ + previous_model, // First turn. + previous_model, // Old-model compaction attempt and retries. + previous_model, + previous_model, + selected_model, // Selected-model compaction attempt and retries. + selected_model, + selected_model, + ]; + if selected_model_failures < 3 { + expected_models.push(selected_model); // Sampling after compaction succeeds. + } + assert_eq!(actual_models, expected_models); +} + +/// Manual compaction keeps the configured retry limit even when the server supplies advice. +#[test_case::test_case(0; "no_retries")] +#[test_case::test_case(2; "two_retries")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn manual_remote_compact_advice_respects_retry_limit(max_retries: u64) { + skip_if_no_network!(); + + let server = start_mock_server().await; + let overload = wiremock::ResponseTemplate::new(/*s*/ 503) + .insert_header("Retry-After", "0") + .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })); + let request_log = + mount_response_sequence(&server, vec![overload; (max_retries + 1) as usize]).await; + let mut model_provider = openai_model_provider(&server); + model_provider.request_max_retries = Some(0); + model_provider.stream_max_retries = Some(max_retries); + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model("gpt-5.4") + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }) + .build_with_auto_env(&server) + .await + .expect("build codex"); + + test.codex.submit(Op::Compact).await.expect("run /compact"); + let mut errors = Vec::new(); + let mut reconnect_messages = Vec::new(); + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Error(error) => errors.push(error.codex_error_info), + EventMsg::StreamError(error) => reconnect_messages.push(error.message), + EventMsg::TurnComplete(event) => { + assert_eq!( + event.error.and_then(|error| error.codex_error_info), + Some(CodexErrorInfo::ServerOverloaded) + ); + break; + } + _ => {} + } + } + assert_eq!(errors, vec![Some(CodexErrorInfo::ServerOverloaded)]); + assert_eq!( + reconnect_messages, + (1..=max_retries) + .map(|retry| format!("Reconnecting... {retry}/{max_retries}")) + .collect::>() + ); + let models = request_log + .requests() + .iter() + .map(|request| { + request.body_json()["model"] + .as_str() + .expect("request model") + .to_string() + }) + .collect::>(); + assert_eq!(models, vec!["gpt-5.4"; (max_retries + 1) as usize]); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_keeps_unknown_previous_model_for_api_key_auth_and_custom_provider() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 1be4f9710fd0..2971b1201d79 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -36,6 +36,7 @@ pub static CODEX_ALIASES_TEMP_DIR: Option = { }) }; +mod abort_lifecycle; #[cfg(not(target_os = "windows"))] mod abort_tasks; mod additional_context; diff --git a/codex-rs/core/tests/suite/retry_after.rs b/codex-rs/core/tests/suite/retry_after.rs index 4bb5da9a8d14..f69caf5208be 100644 --- a/codex-rs/core/tests/suite/retry_after.rs +++ b/codex-rs/core/tests/suite/retry_after.rs @@ -51,11 +51,11 @@ const SECOND_RETRY_MIN_DELAY: Duration = Duration::from_millis(360); const SECOND_RETRY_MAX_DELAY: Duration = Duration::from_millis(440); #[derive(Debug, PartialEq, Eq)] -struct RetryTelemetryEvent { +pub(super) struct RetryTelemetryEvent { attempt: u64, - delay: Duration, - layer: String, - operation: String, + pub(super) delay: Duration, + pub(super) layer: String, + pub(super) operation: String, } #[derive(Default)] @@ -193,7 +193,7 @@ where } } -struct RetryTelemetryCapture { +pub(super) struct RetryTelemetryCapture { events: mpsc::UnboundedReceiver, resumptions: mpsc::UnboundedReceiver, _subscriber: DefaultGuard, @@ -201,7 +201,7 @@ struct RetryTelemetryCapture { } impl RetryTelemetryCapture { - fn install() -> Self { + pub(super) fn install() -> Self { let (sender, events) = mpsc::unbounded_channel(); let (resumptions_sender, resumptions) = mpsc::unbounded_channel(); // Avoid caching no interest when another test first reaches a shared callsite. @@ -225,7 +225,7 @@ impl RetryTelemetryCapture { } } - async fn next_retry(&mut self) -> RetryTelemetryEvent { + pub(super) async fn next_retry(&mut self) -> RetryTelemetryEvent { tokio::time::timeout(Duration::from_secs(10), self.events.recv()) .await .expect("timed out waiting for retry telemetry") @@ -275,7 +275,7 @@ async fn wait_for_turn_completion(test: &TestCodex) { assert_eq!(completed.error, None, "turn should complete successfully"); } -/// HTTP overloads use the upstream header before another HTTP request. +/// HTTP overloads use server advice within the request and stream retry budgets. #[tokio::test(flavor = "current_thread")] async fn responses_http_uses_retry_after() -> Result<()> { skip_if_no_network!(Ok(())); @@ -285,6 +285,9 @@ async fn responses_http_uses_retry_after() -> Result<()> { let response_mock = responses::mount_response_sequence( &server, vec![ + ResponseTemplate::new(503) + .insert_header("Retry-After", "1") + .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })), ResponseTemplate::new(503) .insert_header("Retry-After", "1") .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })), @@ -298,7 +301,7 @@ async fn responses_http_uses_retry_after() -> Result<()> { let test = test_codex() .with_config(|config| { config.model_provider.request_max_retries = Some(1); - config.model_provider.stream_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(1); }) .build_with_auto_env(&server) .await?; @@ -316,8 +319,79 @@ async fn responses_http_uses_retry_after() -> Result<()> { } ); assert!(wait_for_retry(&mut telemetry, &retry).await >= Duration::from_secs(1)); + let retry = telemetry.next_retry().await; + assert!(retry.delay <= Duration::from_secs(1)); + assert_eq!( + retry, + RetryTelemetryEvent { + attempt: 1, + delay: retry.delay, + layer: "stream".into(), + operation: "sampling".into(), + } + ); + assert!(wait_for_retry(&mut telemetry, &retry).await >= Duration::from_secs(1)); wait_for_turn_completion(&test).await; + assert_eq!(response_mock.requests().len(), 3); + assert_eq!( + telemetry.events.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + ); + Ok(()) +} + +/// A 429 with server advice retries in the sampling loop and completes without a terminal error. +#[tokio::test(flavor = "current_thread")] +async fn responses_http_429_uses_retry_after() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut telemetry = RetryTelemetryCapture::install(); + let server = responses::start_mock_server().await; + let response_mock = responses::mount_response_sequence( + &server, + vec![ + ResponseTemplate::new(429) + .insert_header("Retry-After", "1") + .set_body_json(json!({ "error": { "code": "rate_limit_exceeded" } })), + responses::sse_response(responses::sse(vec![ + responses::ev_response_created("recovered"), + responses::ev_completed("recovered"), + ])), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(1); + }) + .build_with_auto_env(&server) + .await?; + + submit_user_input(&test, "retry the rate-limited request").await?; + let retry = telemetry.next_retry().await; + assert!(retry.delay <= Duration::from_secs(1)); + assert_eq!( + retry, + RetryTelemetryEvent { + attempt: 1, + delay: retry.delay, + layer: "stream".into(), + operation: "sampling".into(), + } + ); + assert!(wait_for_retry(&mut telemetry, &retry).await >= Duration::from_secs(1)); + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Error(error) => panic!("unexpected terminal error: {error:?}"), + EventMsg::TurnComplete(completed) => { + assert_eq!(completed.error, None); + break; + } + _ => {} + } + } assert_eq!(response_mock.requests().len(), 2); assert_eq!( telemetry.events.try_recv(), @@ -425,31 +499,40 @@ async fn exhausted_http_retries_preserve_deadline_through_error_mapping() { assert_eq!(outer_error.server_retry_delay(), Some(Duration::ZERO)); } -/// Headerless HTTP overloads currently exhaust request retries before emitting one terminal error. +/// Retry advice changes delays but never extends either retry budget or its displayed count. +#[test_case::test_case(None, 2, 3; "without_advice")] +#[test_case::test_case(Some("0"), 0, 3; "advice_without_stream_retries")] +#[test_case::test_case(Some("0"), 2, 9; "advice_with_stream_retries")] #[tokio::test(flavor = "current_thread")] -async fn responses_http_overload_without_retry_after_exhausts_request_retries() -> Result<()> { +async fn responses_http_overload_respects_retry_limits( + retry_after: Option<&str>, + stream_max_retries: u64, + expected_requests: usize, +) -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; + let mut overload = ResponseTemplate::new(503) + .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })); + if let Some(retry_after) = retry_after { + overload = overload.insert_header("Retry-After", retry_after); + } Mock::given(method("POST")) .and(path("/v1/responses")) - .respond_with( - ResponseTemplate::new(503) - .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })), - ) + .respond_with(overload) .mount(&server) .await; let test = test_codex() - .with_config(|config| { + .with_config(move |config| { config.model_provider.request_max_retries = Some(2); - config.model_provider.stream_max_retries = Some(2); + config.model_provider.stream_max_retries = Some(stream_max_retries); }) .build_with_auto_env(&server) .await?; submit_user_input(&test, "reject the disabled model").await?; let mut error_events = 0; - let mut stream_error_events = 0; + let mut reconnect_messages = Vec::new(); tokio::time::timeout(Duration::from_secs(10), async { loop { match wait_for_event(&test.codex, |_| true).await { @@ -464,7 +547,7 @@ async fn responses_http_overload_without_retry_after_exhausts_request_retries() "Selected model is at capacity. Please try a different model." ); } - EventMsg::StreamError(_) => stream_error_events += 1, + EventMsg::StreamError(error) => reconnect_messages.push(error.message), EventMsg::TurnComplete(event) => { assert_eq!( event.error.and_then(|error| error.codex_error_info), @@ -480,7 +563,14 @@ async fn responses_http_overload_without_retry_after_exhausts_request_retries() .expect("overload retries should finish the turn within 10 seconds"); assert_eq!(error_events, 1); - assert_eq!(stream_error_events, 0); + let expected_messages = if retry_after.is_some() { + (1..=stream_max_retries) + .map(|retry| format!("Reconnecting... {retry}/{stream_max_retries}")) + .collect::>() + } else { + Vec::new() + }; + assert_eq!(reconnect_messages, expected_messages); let request_count = server .received_requests() .await @@ -489,8 +579,8 @@ async fn responses_http_overload_without_retry_after_exhausts_request_retries() .filter(|request| request.url.path() == "/v1/responses") .count(); assert_eq!( - request_count, 3, - "headerless overload should exhaust the configured request retries" + request_count, expected_requests, + "overload should exhaust the configured retry budgets" ); Ok(()) @@ -1709,6 +1799,79 @@ async fn websocket_connection_limit_retries_with_local_backoff() -> Result<()> { Ok(()) } +/// Advised WebSocket failures reach HTTP after the existing retry budget. HTTP +/// recovery uses its own retry budget and honors the preceding advice deadlines. +#[tokio::test(flavor = "current_thread")] +async fn websocket_upgrade_rejection_uses_retry_after() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let prewarm_rejection = Mock::given(method("GET")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(/*s*/ 503)) + .up_to_n_times(/*n*/ 1) + .expect(/*r*/ 1) + .mount_as_scoped(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(/*s*/ 503) + .insert_header("Retry-After", "1") + .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })), + ) + .mount(&server) + .await; + let http_overload = ResponseTemplate::new(/*s*/ 503) + .insert_header("Retry-After", "0") + .set_body_json(json!({ "error": { "code": "server_is_overloaded" } })); + let response_mock = responses::mount_response_sequence( + &server, + vec![ + http_overload, + ResponseTemplate::new(/*s*/ 200) + .insert_header("content-type", "text/event-stream") + .set_body_string(responses::sse(vec![ + responses::ev_response_created("recovered"), + responses::ev_completed("recovered"), + ])), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_catalog = Some(bundled_models_response().expect("bundled models")); + config.model_provider.supports_websockets = true; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(1); + }) + .build_with_auto_env(&server) + .await?; + + // Prewarm is best effort; start the advised failures in the sampling retry loop. + tokio::time::timeout( + Duration::from_secs(10), + prewarm_rejection.wait_until_satisfied(), + ) + .await?; + drop(prewarm_rejection); + let start = Instant::now(); + submit_user_input(&test, "retry the rejected websocket upgrade").await?; + wait_for_turn_completion(&test).await; + + // Both WebSocket advice deadlines apply, including the one before transport fallback. + assert!(start.elapsed() >= Duration::from_secs(2)); + let requests = server.received_requests().await.unwrap_or_default(); + let methods = requests + .iter() + .filter(|request| request.url.path() == "/v1/responses") + .map(|request| request.method.as_str()) + .collect::>(); + assert_eq!(methods, vec!["GET", "GET", "GET", "POST", "POST"]); + assert_eq!(response_mock.requests().len(), 2); + Ok(()) +} + // TODO(anp) respect Retry-After /// Nested websocket retry headers are currently ignored, leaving rate-limit errors terminal. #[tokio::test(flavor = "current_thread")] diff --git a/codex-rs/core/tests/suite/unified_exec_process_events.rs b/codex-rs/core/tests/suite/unified_exec_process_events.rs index 39d8db419093..983fc2ded343 100644 --- a/codex-rs/core/tests/suite/unified_exec_process_events.rs +++ b/codex-rs/core/tests/suite/unified_exec_process_events.rs @@ -5,6 +5,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::test_support::CloudConfigBundleFixture; use codex_core::TurnInputRequest; use codex_exec_server::ExecParams; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -71,6 +72,11 @@ const REPLAY_RETAINED_OUTPUT_SEQ: u64 = 800; #[cfg_attr(windows, allow(dead_code))] enum PushedExecScenario { Complete, + CodexPath, + CodexPathDefaultFeature, + CodexPathDisabledFeature, + CodexPathWithExplicitOverride, + UnusableCodexPath, DirectDenied, DenyWin, DenyWinUnchanged, @@ -196,18 +202,29 @@ async fn respond_environment_info( if matches!(scenario, PushedExecScenario::DenyLinux) { capabilities["linuxApprovedRootWritePreservesRestrictions"] = json!(true); } - send_exec_server_json( - websocket, - json!({ - "id": id, - "result": { - "shell": shell, - "platformOs": platform_os, - "capabilities": capabilities, - } - }), - ) - .await; + let mut response = json!({ + "id": id, + "result": { + "shell": shell, + "platformOs": platform_os, + "capabilities": capabilities, + } + }); + if matches!( + scenario, + PushedExecScenario::CodexPath + | PushedExecScenario::CodexPathDefaultFeature + | PushedExecScenario::CodexPathDisabledFeature + | PushedExecScenario::CodexPathWithExplicitOverride + ) { + response["result"]["prependPathDirs"] = json!([ + "file:///executor/codex-path", + "file:///executor/extra-tools", + ]); + } else if matches!(scenario, PushedExecScenario::UnusableCodexPath) { + response["result"]["prependPathDirs"] = json!(["file:///executor/codex%3Apath"]); + } + send_exec_server_json(websocket, response).await; } async fn serve_exec_with_pushed_events( @@ -410,6 +427,11 @@ async fn serve_exec_with_pushed_events( | PushedExecScenario::DenyOldLinux | PushedExecScenario::DenyOldLinuxWithRoot(_) | PushedExecScenario::DenyLinux + | PushedExecScenario::CodexPath + | PushedExecScenario::CodexPathDefaultFeature + | PushedExecScenario::CodexPathDisabledFeature + | PushedExecScenario::CodexPathWithExplicitOverride + | PushedExecScenario::UnusableCodexPath | PushedExecScenario::ElevatedPowerShell => { let encoded_output = BASE64_STANDARD.encode(COMPLETE_OUTPUT); for message in [ @@ -491,7 +513,12 @@ async fn serve_exec_with_pushed_events( Some("process/read") => { process_read_requests += 1; let result = match scenario { - PushedExecScenario::Complete => json!({ + PushedExecScenario::Complete + | PushedExecScenario::CodexPath + | PushedExecScenario::CodexPathDefaultFeature + | PushedExecScenario::CodexPathDisabledFeature + | PushedExecScenario::CodexPathWithExplicitOverride + | PushedExecScenario::UnusableCodexPath => json!({ "chunks": [{ "seq": 1, "stream": "stdout", @@ -606,6 +633,11 @@ async fn serve_exec_with_pushed_events( } #[test_case(PushedExecScenario::Complete, ManagedNetworkScenario::None, false ; "complete_event_stream")] +#[test_case(PushedExecScenario::CodexPath, ManagedNetworkScenario::None, false ; "codex_path_is_exported_before_nested_command")] +#[test_case(PushedExecScenario::CodexPathDefaultFeature, ManagedNetworkScenario::None, false ; "codex_path_is_not_exported_by_default")] +#[test_case(PushedExecScenario::CodexPathDisabledFeature, ManagedNetworkScenario::None, false ; "disabled_codex_path_preserves_original_login_command")] +#[test_case(PushedExecScenario::CodexPathWithExplicitOverride, ManagedNetworkScenario::None, false ; "explicit_path_override_preserves_original_login_command")] +#[test_case(PushedExecScenario::UnusableCodexPath, ManagedNetworkScenario::None, false ; "unusable_codex_path_preserves_requested_login_shell")] #[test_case(PushedExecScenario::DirectDenied, ManagedNetworkScenario::None, false ; "direct_sandbox_denial")] #[test_case(PushedExecScenario::LegacyExit, ManagedNetworkScenario::None, false ; "legacy_exit_metadata")] #[test_case(PushedExecScenario::ReplayGap, ManagedNetworkScenario::None, false ; "truncated_event_replay")] @@ -634,6 +666,7 @@ async fn exec_command_consumes_pushed_remote_process_events( managed_network: ManagedNetworkScenario, foreign_cwd: bool, ) -> Result<()> { + let explicit_path = "/user/configured/bin:/user/other/bin"; let managed_network_configured = !matches!(managed_network, ManagedNetworkScenario::None); let managed_network_enabled = matches!(managed_network, ManagedNetworkScenario::Enabled { .. }); let policy_callbacks = matches!( @@ -669,6 +702,16 @@ async fn exec_command_consumes_pushed_remote_process_events( CALL_ID, "*** Begin Patch\n*** Update File: secret.txt\n@@\n-old\n+new\n*** End Patch", ), + PushedExecScenario::CodexPathWithExplicitOverride | PushedExecScenario::UnusableCodexPath => ev_function_call( + CALL_ID, + "exec_command", + &json!({ + "cmd": "sh -c 'command -v rg'", + "login": true, + "yield_time_ms": 1_000, + }) + .to_string(), + ), _ => ev_function_call( CALL_ID, "exec_command", @@ -685,6 +728,9 @@ async fn exec_command_consumes_pushed_remote_process_events( "Remove-Item test -Force; {}", "Write-Output filler; ".repeat(2_000) ), + PushedExecScenario::CodexPath + | PushedExecScenario::CodexPathDefaultFeature + | PushedExecScenario::CodexPathDisabledFeature => "sh -c 'command -v rg'".to_string(), _ => "pwd".to_string(), }, "yield_time_ms": 1_000, @@ -777,6 +823,24 @@ timeout = 900 } let mut builder = builder.with_config(move |config| { config.project_doc_max_bytes = 0; + if matches!(scenario, PushedExecScenario::CodexPathDisabledFeature) { + config + .features + .disable(Feature::LoginShellPackagePath) + .expect("disable login shell package PATH"); + } else if !matches!(scenario, PushedExecScenario::CodexPathDefaultFeature) { + config + .features + .enable(Feature::LoginShellPackagePath) + .expect("enable login shell package PATH"); + } + if matches!(scenario, PushedExecScenario::CodexPathWithExplicitOverride) { + config + .permissions + .shell_environment_policy + .r#set + .insert("PATH".to_string(), explicit_path.to_string()); + } if matches!(scenario, PushedExecScenario::ElevatedPowerShell) || (foreign_cwd && deny_read) { config.set_windows_elevated_sandbox_enabled(/*value*/ true); @@ -930,6 +994,7 @@ timeout = 900 ) .await?; let mut saw_exec_command_begin = false; + let mut exec_command_begin_command = None; let mut saw_patch_denial_approval = false; if !managed_network_enabled { loop { @@ -940,6 +1005,7 @@ timeout = 900 match event { EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => { saw_exec_command_begin = true; + exec_command_begin_command = Some(event.command); } EventMsg::ExecApprovalRequest(approval) if deny_read => { assert_eq!(approval.call_id, CALL_ID); @@ -1169,6 +1235,51 @@ timeout = 900 "#); } } + if matches!(scenario, PushedExecScenario::CodexPath) { + let argv = &exec_server_result.process_start["params"]["argv"]; + assert_eq!(argv[0], "/bin/zsh"); + assert_eq!(argv[1], "-lc"); + let script = argv[2] + .as_str() + .context("shell command should contain a script")?; + assert!(script.contains("export PATH=/executor/codex-path")); + assert!(script.contains("export PATH=/executor/extra-tools")); + assert!(script.ends_with("; sh -c 'command -v rg'")); + assert_eq!(script.lines().count(), 1); + assert_eq!( + exec_command_begin_command, + Some(vec![ + "/bin/zsh".to_string(), + "-lc".to_string(), + "sh -c 'command -v rg'".to_string() + ]) + ); + } else if matches!( + scenario, + PushedExecScenario::CodexPathDefaultFeature + | PushedExecScenario::CodexPathDisabledFeature + | PushedExecScenario::CodexPathWithExplicitOverride + | PushedExecScenario::UnusableCodexPath + ) { + let params = &exec_server_result.process_start["params"]; + let expected = vec!["/bin/zsh", "-lc", "sh -c 'command -v rg'"]; + assert_eq!(params["argv"], json!(expected)); + assert_eq!( + exec_command_begin_command, + Some(expected.into_iter().map(str::to_string).collect()) + ); + if matches!(scenario, PushedExecScenario::CodexPathWithExplicitOverride) { + assert_eq!(params["envPolicy"]["set"]["PATH"], explicit_path); + } + } else if matches!(scenario, PushedExecScenario::Complete) + && !foreign_cwd + && !managed_network_configured + { + assert_eq!( + exec_server_result.process_start["params"]["argv"], + json!(["/bin/zsh", "-lc", "pwd"]) + ); + } if foreign_cwd { let params = &exec_server_result.process_start["params"]; assert_eq!(params["cwd"], "file:///C:/workspace"); @@ -1263,6 +1374,11 @@ timeout = 900 | PushedExecScenario::DenyOldLinux | PushedExecScenario::DenyOldLinuxWithRoot(_) | PushedExecScenario::DenyLinux + | PushedExecScenario::CodexPath + | PushedExecScenario::CodexPathDefaultFeature + | PushedExecScenario::CodexPathDisabledFeature + | PushedExecScenario::CodexPathWithExplicitOverride + | PushedExecScenario::UnusableCodexPath | PushedExecScenario::ElevatedPowerShell => { assert_ne!(success, Some(false)); assert!(saw_exec_command_begin); diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 71617b49188b..082ad982464b 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -317,6 +317,7 @@ allow-git = [ "https://github.com/hyperium/h3", "https://github.com/juberti-oai/rust-sdks.git", "https://github.com/microsoft/mxc", + "https://github.com/modelcontextprotocol/rust-sdk", "https://github.com/openai-oss-forks/crossterm", "https://github.com/openai-oss-forks/tokio-tungstenite", "https://github.com/openai-oss-forks/tungstenite-rs", diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index bb2b162ce0ae..09892600d7e8 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -247,6 +247,7 @@ pub trait TurnLifecycleContributor: Send + Sync { } /// Called before the host drops the completed turn runtime and turn store. + /// The callback completes before the host emits the turn-complete event. fn on_turn_stop<'a>(&'a self, input: TurnStopInput<'a>) -> ExtensionFuture<'a, ()> { Box::pin(async move { let _self = self; @@ -255,6 +256,7 @@ pub trait TurnLifecycleContributor: Send + Sync { } /// Called after the host aborts a running turn. + /// The callback completes before the host emits the turn-aborted event, if any. fn on_turn_abort<'a>(&'a self, input: TurnAbortInput<'a>) -> ExtensionFuture<'a, ()> { Box::pin(async move { let _self = self; diff --git a/codex-rs/login/src/internal_identity.rs b/codex-rs/login/src/internal_identity.rs deleted file mode 100644 index 95ac0cc959fc..000000000000 --- a/codex-rs/login/src/internal_identity.rs +++ /dev/null @@ -1 +0,0 @@ -//! Authentication identity helpers for optional diagnostics. diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index df310c58c2e0..559ec0a9b184 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -78,6 +78,7 @@ pub use network_policy::NetworkPolicyDecision; pub use network_policy::NetworkPolicyRequest; pub use network_policy::NetworkPolicyRequestArgs; pub use network_policy::NetworkProtocol; +pub use policy::is_non_public_ip; pub use policy::normalize_host; pub use process_log_metadata::ExecutorLogIdentity; pub use process_log_metadata::NetworkProxyProcessLogMetadata; diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index 0cfd7c04c7c5..3b2486be1fa2 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -52,6 +52,7 @@ pub(crate) fn is_default_proxy_bypass_host(host: &str) -> bool { } } +/// Classifies IP literals excluded from public network destinations; does not resolve DNS. pub fn is_non_public_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => is_non_public_ipv4(ip), diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index d0dad844bd54..19134d6f3e59 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -403,18 +403,19 @@ impl CodexErr { | CodexErrorDetails::UnsupportedOperation(_) | CodexErrorDetails::Sandbox(_) | CodexErrorDetails::LandlockSandboxExecutableNotProvided - | CodexErrorDetails::RetryLimit(_) | CodexErrorDetails::ContextWindowExceeded | CodexErrorDetails::ThreadNotFound(_) | CodexErrorDetails::AgentLimitReached { .. } | CodexErrorDetails::Spawn | CodexErrorDetails::SessionConfiguredNotFirstEvent | CodexErrorDetails::UsageLimitReached(_) - | CodexErrorDetails::ServerOverloaded | CodexErrorDetails::FlexUnavailable | CodexErrorDetails::CyberPolicy { .. } | CodexErrorDetails::BioPolicy { .. } | CodexErrorDetails::MisalignmentPolicyViolation { .. } => None, + CodexErrorDetails::ServerOverloaded | CodexErrorDetails::RetryLimit(_) => { + self.server_retry_delay() + } CodexErrorDetails::Stream(..) | CodexErrorDetails::ContentFilter | CodexErrorDetails::RateLimitExceeded(_) diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index 3e5c62e74121..1a409c4931c6 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -34,6 +34,9 @@ pub const OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID: &str = "openai/standard-form- pub const MCP_APP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui"; /// Host-supplied confirmation-policy documents for Node REPL-backed actor calls. pub const CONFIRMATION_POLICIES_META_KEY: &str = "openai/confirmation_policies"; +/// Extension ID for enterprise-managed MCP authorization via ID-JAG. +pub const ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID: &str = + "io.modelcontextprotocol/enterprise-managed-authorization"; /// Returns whether a raw MCP server name identifies a Node REPL-backed server. pub fn is_node_repl_backed_server(server: &str) -> bool { diff --git a/codex-rs/realtime-webrtc/src/client.rs b/codex-rs/realtime-webrtc/src/client.rs index ed2f50e37fa3..d6ef6af2072b 100644 --- a/codex-rs/realtime-webrtc/src/client.rs +++ b/codex-rs/realtime-webrtc/src/client.rs @@ -12,6 +12,9 @@ use codex_utils_pty::SpawnedProcess; use tokio::sync::oneshot; use tokio::time::timeout; +use crate::AudioDevice; +use crate::AudioDeviceKind; +use crate::AudioDeviceSelection; use crate::HelperExitStage; use crate::Message; use crate::encode_frame; @@ -63,12 +66,27 @@ pub struct VoiceHost { impl VoiceHost { /// Open local devices only after answer negotiation. They initially remain muted/suppressed. - pub async fn open_devices(mut self) -> Result { - self.exchange(Message::OpenDevices {}, Message::DevicesOpened {}, DEADLINE) - .await?; + pub async fn open_devices(mut self, selection: AudioDeviceSelection) -> Result { + self.exchange( + Message::OpenDevices { selection }, + Message::DevicesOpened {}, + DEADLINE, + ) + .await?; Ok(self) } + /// Enumerate local devices without opening streams. + pub async fn list_devices(&mut self, kind: AudioDeviceKind) -> Result> { + match self + .request(Message::ListDevices { kind }, DEADLINE) + .await? + { + Message::DeviceList { devices } => Ok(devices), + _ => anyhow::bail!("unexpected voice helper response"), + } + } + /// Acknowledgement follows invalidation of the helper's previous capture/render generations. pub async fn set_audio_controls(&mut self, controls: crate::AudioControls) -> Result<()> { self.exchange( diff --git a/codex-rs/realtime-webrtc/src/lib.rs b/codex-rs/realtime-webrtc/src/lib.rs index d4776cfcdda1..988365628cd1 100644 --- a/codex-rs/realtime-webrtc/src/lib.rs +++ b/codex-rs/realtime-webrtc/src/lib.rs @@ -10,6 +10,9 @@ pub use client::ConnectionError; pub use client::VoiceHost; pub use helper_exit::HelperExitStage; pub use protocol::AudioControls; +pub use protocol::AudioDevice; +pub use protocol::AudioDeviceKind; +pub use protocol::AudioDeviceSelection; pub use protocol::AudioState; pub use protocol::MAX_FRAME_BYTES; pub use protocol::Message; diff --git a/codex-rs/realtime-webrtc/src/protocol.rs b/codex-rs/realtime-webrtc/src/protocol.rs index 2859aaea870f..6c067243b485 100644 --- a/codex-rs/realtime-webrtc/src/protocol.rs +++ b/codex-rs/realtime-webrtc/src/protocol.rs @@ -23,6 +23,31 @@ pub struct AudioState { pub speaker_peak: u16, } +/// Direction of a local audio device, shared by enumeration and capture/playback selection. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AudioDeviceKind { + Input, + Output, +} + +/// Local device preferences fixed for the lifetime of a voice conversation. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AudioDeviceSelection { + pub microphone: Option, + pub speaker: Option, +} + +/// Local device metadata; enumeration never opens streams. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AudioDevice { + pub name: String, + pub channels: u16, + pub is_default: bool, +} + /// SDP contains ICE credentials. Bound it at construction and never expose it in diagnostics. #[derive(Deserialize, PartialEq, Serialize)] #[serde(try_from = "String")] @@ -82,7 +107,9 @@ pub enum Message { ApplyAnswer { sdp: SessionDescription }, TransportReady {}, TransportTimedOut {}, - OpenDevices {}, + ListDevices { kind: AudioDeviceKind }, + DeviceList { devices: Vec }, + OpenDevices { selection: AudioDeviceSelection }, DevicesOpened {}, SetAudioControls { controls: AudioControls }, AudioControlsApplied {}, diff --git a/codex-rs/realtime-webrtc/src/session.rs b/codex-rs/realtime-webrtc/src/session.rs index fee0c3966971..badbf41e388e 100644 --- a/codex-rs/realtime-webrtc/src/session.rs +++ b/codex-rs/realtime-webrtc/src/session.rs @@ -16,6 +16,7 @@ use futures::future::Abortable; use tokio::sync::mpsc; use crate::AudioControls; +use crate::AudioDeviceSelection; use crate::ConnectionError; use crate::SessionDescription; use crate::VoiceHost; @@ -93,7 +94,10 @@ impl RealtimeWebrtcSession { } /// Called off the UI thread. Cancellation also owns startup before a handle is returned. - pub fn start(abort: AbortRegistration) -> Result { + pub fn start( + abort: AbortRegistration, + selection: AudioDeviceSelection, + ) -> Result { let package = codex_install_context::InstallContext::current() .package_layout .clone() @@ -147,7 +151,7 @@ impl RealtimeWebrtcSession { offer .send(Ok(sdp.into_sdp())) .map_err(|_| anyhow::anyhow!("voice startup cancelled"))?; - run(host, receiver, &state, &controls).await + run(host, receiver, &state, &controls, selection).await }; let result = runtime.block_on(Abortable::new(Abortable::new(task, abort), stopped)); if let Ok(Ok(Err(error))) = result { @@ -266,6 +270,7 @@ async fn run( mut commands: mpsc::Receiver, state: &State, controls: &Mutex, + selection: AudioDeviceSelection, ) -> Result<()> { let mut connected = false; let mut poll = tokio::time::interval(Duration::from_millis(/*millis*/ 50)); @@ -277,7 +282,7 @@ async fn run( Some(Command::Answer(sdp, complete)) if !connected => { let startup = async { let mut host = report_failure(ConnectionError::Transport, host.apply_answer(sdp).await)?; - host = report_failure(ConnectionError::AudioDevices, host.open_devices().await)?; + host = report_failure(ConnectionError::AudioDevices, host.open_devices(selection.clone()).await)?; let applied = startup_controls(&mut commands, controls, |initial| { host.begin_audio_controls(initial) }); diff --git a/codex-rs/realtime-webrtc/tests/common/mod.rs b/codex-rs/realtime-webrtc/tests/common/mod.rs index 63f8884b48eb..137012d28cae 100644 --- a/codex-rs/realtime-webrtc/tests/common/mod.rs +++ b/codex-rs/realtime-webrtc/tests/common/mod.rs @@ -148,6 +148,17 @@ fn helper() -> Result<()> { stage = Stage::Initialize; Message::Ready {} } + Message::ListDevices { kind } if stage == Stage::Initialize => Message::DeviceList { + devices: vec![codex_realtime_webrtc::AudioDevice { + name: match kind { + codex_realtime_webrtc::AudioDeviceKind::Input => "Interface", + codex_realtime_webrtc::AudioDeviceKind::Output => "Headphones", + } + .into(), + channels: 16, + is_default: true, + }], + }, Message::InitializeRuntime {} if stage == Stage::Initialize => { fs::write(root.join("initializing"), [])?; if root.join("hold-initialization").exists() { @@ -178,7 +189,11 @@ fn helper() -> Result<()> { stage = Stage::Devices; Message::TransportReady {} } - Message::OpenDevices {} if stage == Stage::Devices => { + Message::OpenDevices { selection } if stage == Stage::Devices => { + fs::write( + root.join("input-selection"), + serde_json::to_vec(&selection)?, + )?; if root.join("fail-devices").exists() { anyhow::bail!("synthetic private device diagnostic"); } diff --git a/codex-rs/realtime-webrtc/tests/session_actor.rs b/codex-rs/realtime-webrtc/tests/session_actor.rs index ded26288cefa..524f5ded1424 100644 --- a/codex-rs/realtime-webrtc/tests/session_actor.rs +++ b/codex-rs/realtime-webrtc/tests/session_actor.rs @@ -4,6 +4,8 @@ mod common; use anyhow::Result; use codex_realtime_webrtc::AudioControls; +use codex_realtime_webrtc::AudioDeviceKind; +use codex_realtime_webrtc::AudioDeviceSelection; use codex_realtime_webrtc::RealtimeWebrtcSession; use futures::future::AbortHandle; use pretty_assertions::assert_eq; @@ -24,7 +26,13 @@ fn startup_controls_meters_and_helper_loss() -> Result<()> { assert!(RealtimeWebrtcSession::is_supported()); } let (_abort, registration) = AbortHandle::new_pair(); - let started = RealtimeWebrtcSession::start(registration)?; + let started = RealtimeWebrtcSession::start( + registration, + AudioDeviceSelection { + microphone: Some("Zen Go Synergy Core Playback".into()), + speaker: Some("Headphones".into()), + }, + )?; assert_eq!(started.offer_sdp, "synthetic-offer"); let handle = started.handle; handle.set_microphone_muted(/*muted*/ true)?; @@ -34,6 +42,10 @@ fn startup_controls_meters_and_helper_loss() -> Result<()> { handle.set_speaker_suppressed(/*suppressed*/ true); fs::write(root.join("release"), [])?; answer.join().expect("answer thread")?; + assert_eq!( + serde_json::from_slice::(&fs::read(root.join("input-selection"))?)?, + serde_json::json!({"microphone": "Zen Go Synergy Core Playback", "speaker": "Headphones"}) + ); let initial: Vec = serde_json::from_slice(&fs::read(root.join("controls"))?)?; assert_eq!( initial, @@ -84,7 +96,10 @@ fn external_cancellation_interrupts_startup() -> Result<()> { let (abort, registration) = AbortHandle::new_pair(); let (result, received) = std::sync::mpsc::sync_channel(/*bound*/ 1); let startup = thread::spawn(move || { - let _ = result.send(RealtimeWebrtcSession::start(registration)); + let _ = result.send(RealtimeWebrtcSession::start( + registration, + AudioDeviceSelection::default(), + )); }); common::wait_for(|| root.join("initializing").exists())?; abort.abort(); @@ -106,7 +121,7 @@ fn last_owner_drop_reaps_helper() -> Result<()> { return Ok(()); }; let (_abort, registration) = AbortHandle::new_pair(); - let started = RealtimeWebrtcSession::start(registration)?; + let started = RealtimeWebrtcSession::start(registration, AudioDeviceSelection::default())?; drop(started); common::wait_for_helper_reaped(&root) } @@ -121,7 +136,7 @@ fn device_failure_reaches_startup_completion_without_duplicate_error() -> Result fs::write(root.join("fail-devices"), [])?; fs::write(root.join("release"), [])?; let (_abort, registration) = AbortHandle::new_pair(); - let started = RealtimeWebrtcSession::start(registration)?; + let started = RealtimeWebrtcSession::start(registration, AudioDeviceSelection::default())?; assert_eq!( started.handle.apply_answer_sdp("synthetic-answer".into()), Err(codex_realtime_webrtc::ConnectionError::AudioDevices) @@ -141,7 +156,8 @@ fn runtime_failure_reaches_offer_caller_without_native_error_text() -> Result<() }; fs::write(root.join("fail-initialization"), [])?; let (_abort, registration) = AbortHandle::new_pair(); - let error = RealtimeWebrtcSession::start(registration).unwrap_err(); + let error = + RealtimeWebrtcSession::start(registration, AudioDeviceSelection::default()).unwrap_err(); assert_eq!( error.downcast_ref::(), Some(&codex_realtime_webrtc::ConnectionError::RuntimeInitialization) @@ -154,3 +170,26 @@ fn runtime_failure_reaches_offer_caller_without_native_error_text() -> Result<() common::wait_for_helper_reaped(&root)?; Ok(()) } + +#[tokio::test] +async fn device_discovery_does_not_start_capture() -> Result<()> { + let Some(root) = common::package("device_discovery_does_not_start_capture")? else { + return Ok(()); + }; + let package = codex_install_context::InstallContext::current() + .package_layout + .as_ref() + .unwrap(); + let mut host = codex_realtime_webrtc::VoiceHost::connect(package, common::BUILD_COMMIT).await?; + assert_eq!( + host.list_devices(AudioDeviceKind::Input).await?, + vec![codex_realtime_webrtc::AudioDevice { + name: "Interface".into(), + channels: 16, + is_default: true, + }] + ); + host.close().await?; + assert!(!root.join("initializing").exists()); + Ok(()) +} diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 671bfd9ecc97..d44f91328d98 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -34,7 +34,7 @@ keyring = { workspace = true, features = ["crypto-rust"] } memchr = { workspace = true } oauth2 = "5" rmcp = { workspace = true, default-features = false, features = [ - "auth", + "auth-enterprise-managed", "base64", "client", "macros", diff --git a/codex-rs/rmcp-client/src/ema_auth.rs b/codex-rs/rmcp-client/src/ema_auth.rs new file mode 100644 index 000000000000..5d5f5de64b79 --- /dev/null +++ b/codex-rs/rmcp-client/src/ema_auth.rs @@ -0,0 +1,205 @@ +//! Select the resource authorization server before polling identity or exchanging credentials. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use codex_exec_server::HttpClient; +use futures::future::BoxFuture; +use http::StatusCode; +use serde::Deserialize; +use serde_json::Value; +use url::Url; + +use crate::ema_auth_policy::advertised_capability; +use crate::ema_auth_policy::validate_ema_auth_resource; +use crate::ema_auth_policy::validate_ema_oauth_endpoint; +use crate::ema_auth_policy::validate_ema_public_client_auth; +use crate::ema_exchange::EmaAccessToken; +use crate::ema_exchange::EmaIdJagExchangeRequest; +use crate::ema_exchange::JWT_BEARER_GRANT_TYPE; +use crate::ema_exchange::exchange_id_jag; +use crate::ema_identity::EmaIdpIdentity; +use crate::ema_resource::EmaDiscoveryRequest; +use crate::ema_resource::EmaResource; +use crate::ema_resource::discover_ema_resource; +use crate::ema_resource::validate_ema_metadata_issuer; + +const ID_JAG_GRANT_PROFILE: &str = "urn:ietf:params:oauth:grant-profile:id-jag"; + +/// Token-only Resource AS metadata must not require an unused authorization endpoint. +#[derive(Deserialize)] +struct EmaAuthorizationMetadata { + issuer: Option, + token_endpoint: String, + #[serde(flatten)] + additional_fields: HashMap, +} + +/// Inputs for non-interactive enterprise-managed MCP authorization. +pub struct EmaAuthTokenExchangeRequest<'a> { + pub server_url: &'a str, + pub resource: Option<&'a str>, + pub scopes: &'a [String], + pub mcp_client_id: Option<&'a str>, + pub expected_authorization_server_issuer: Option<&'a str>, + pub idp_issuer: &'a str, + pub idp_client_id: &'a str, + pub idp_identity: BoxFuture<'a, Result>, + pub idp_http_client: Arc, + pub resource_http_client: Arc, +} + +impl<'a> From<&EmaAuthTokenExchangeRequest<'a>> for EmaDiscoveryRequest<'a> { + fn from(request: &EmaAuthTokenExchangeRequest<'a>) -> Self { + Self { + server_url: request.server_url, + resource: request.resource, + expected_issuer: request.expected_authorization_server_issuer, + http_client: Arc::clone(&request.resource_http_client), + } + } +} + +/// Discover the resource's authorization server, then perform the ID-JAG flow. +pub async fn exchange_ema_auth_token( + request: EmaAuthTokenExchangeRequest<'_>, +) -> Result { + validate_ema_auth_resource(request.server_url, request.resource)?; + let client_id = request + .mcp_client_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + anyhow!( + "ema_auth requires the OAuth client ID registered with the MCP authorization server" + ) + })?; + let (metadata, resource) = + discover_ema_authorization_metadata(EmaDiscoveryRequest::from(&request)).await?; + let issuer = metadata + .issuer + .as_deref() + .ok_or_else(|| anyhow!("MCP authorization metadata is missing an issuer"))?; + let EmaIdpIdentity { + token_endpoint, + refresh_token, + credential_lock, + } = request.idp_identity.await?; + let result = exchange_id_jag(EmaIdJagExchangeRequest { + resource: &resource, + scopes: request.scopes, + mcp_client_id: client_id, + authorization_server_issuer: issuer, + authorization_server_token_endpoint: &metadata.token_endpoint, + idp_token_endpoint: &token_endpoint, + idp_issuer: request.idp_issuer, + idp_client_id: request.idp_client_id, + refresh_token, + idp_http_client: request.idp_http_client, + resource_http_client: request.resource_http_client, + }) + .await; + drop(credential_lock); + result +} + +async fn discover_ema_authorization_metadata( + request: EmaDiscoveryRequest<'_>, +) -> Result<(EmaAuthorizationMetadata, String)> { + let expected_issuer = request.expected_issuer; + let EmaResource { + server_url: server, + resource, + authorization_servers: issuers, + client, + } = discover_ema_resource(request).await?; + for issuer in issuers { + let issuer_url = match Url::parse(&issuer) { + Ok(url) => url, + Err(error) if expected_issuer.is_some() => { + return Err(error) + .context("configured enterprise authorization server issuer is invalid"); + } + Err(_) => continue, + }; + if let Err(error) = validate_ema_metadata_issuer(&server, &issuer_url) { + if expected_issuer.is_some() { + return Err(error); + } + continue; + } + let path = issuer_url.path().trim_matches('/'); + let suffix = if path.is_empty() { + String::new() + } else { + format!("/{path}") + }; + let mut urls = vec![ + issuer_url.join(&format!("/.well-known/oauth-authorization-server{suffix}"))?, + issuer_url.join(&format!("/.well-known/openid-configuration{suffix}"))?, + ]; + if !path.is_empty() { + urls.push(issuer_url.join(&format!("/{path}/.well-known/openid-configuration"))?); + urls.push(issuer_url.join("/.well-known/oauth-authorization-server")?); + } + for url in urls { + let response = client.get(url).await?; + if response.status() != StatusCode::OK { + continue; + } + let Ok(metadata) = serde_json::from_slice::(response.body()) + else { + continue; + }; + if metadata.issuer.as_deref() != Some(issuer.as_str()) { + bail!( + "enterprise authorization metadata issuer does not match its advertised issuer" + ); + } + validate_ema_oauth_endpoint( + &metadata.token_endpoint, + "MCP authorization server token endpoint", + )?; + if expected_issuer.is_some() + && issuer_url.origin() != Url::parse(&metadata.token_endpoint)?.origin() + { + bail!("enterprise MCP authorization server token endpoint changed origin"); + } + let profile = advertised_capability( + metadata + .additional_fields + .get("authorization_grant_profiles_supported"), + ID_JAG_GRANT_PROFILE, + "MCP authorization grant profiles", + )?; + let grant = advertised_capability( + metadata.additional_fields.get("grant_types_supported"), + JWT_BEARER_GRANT_TYPE, + "MCP authorization grant types", + )?; + if profile == Some(false) || grant != Some(true) { + break; + } + match validate_ema_public_client_auth( + metadata + .additional_fields + .get("token_endpoint_auth_methods_supported"), + "MCP authorization server", + ) { + Ok(()) => return Ok((metadata, resource)), + Err(error) if expected_issuer.is_some() => return Err(error), + Err(_) => break, + } + } + } + bail!( + "enterprise MCP discovery found no authorization server advertising public-client JWT bearer support for ID-JAG" + ) +} + +#[cfg(test)] +#[path = "ema_auth_tests.rs"] +mod tests; diff --git a/codex-rs/rmcp-client/src/ema_auth_policy.rs b/codex-rs/rmcp-client/src/ema_auth_policy.rs index 42057b1f7873..ddedfe1333da 100644 --- a/codex-rs/rmcp-client/src/ema_auth_policy.rs +++ b/codex-rs/rmcp-client/src/ema_auth_policy.rs @@ -29,26 +29,6 @@ pub(crate) fn ema_reauthentication_required(message: &'static str) -> anyhow::Er anyhow::Error::new(EmaAuthFailure::ReauthenticationRequired).context(message) } -pub(crate) fn safe_oauth_error_code(code: Option<&str>) -> &str { - code.filter(|code| { - matches!( - *code, - "invalid_request" - | "invalid_client" - | "invalid_grant" - | "invalid_scope" - | "invalid_target" - | "unauthorized_client" - | "unsupported_grant_type" - | "access_denied" - | "temporarily_unavailable" - | "server_error" - | "insufficient_user_authentication" - ) - }) - .unwrap_or("OAuth token request rejected") -} - pub(crate) fn validate_ema_public_client_auth( advertised_methods: Option<&Value>, issuer_description: &str, diff --git a/codex-rs/rmcp-client/src/ema_auth_tests.rs b/codex-rs/rmcp-client/src/ema_auth_tests.rs new file mode 100644 index 000000000000..d327ae34c299 --- /dev/null +++ b/codex-rs/rmcp-client/src/ema_auth_tests.rs @@ -0,0 +1,377 @@ +//! Bound metadata selection and HTTP confinement run before enterprise credential access. + +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use codex_exec_server::RouteAwareHttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::*; + +fn request<'a>(resource: &'a str, issuer: Option<&'a str>) -> EmaAuthTokenExchangeRequest<'a> { + let client: Arc = Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))); + EmaAuthTokenExchangeRequest { + server_url: resource, + resource: Some(resource), + scopes: &[], + mcp_client_id: Some("resource-client"), + expected_authorization_server_issuer: issuer, + idp_issuer: "https://idp.example", + idp_client_id: "enterprise-client", + idp_identity: Box::pin(async { bail!("identity resolver reached") }), + idp_http_client: Arc::clone(&client), + resource_http_client: client, + } +} + +#[tokio::test] +async fn discovery_pins_resource_issuer_and_public_jwt_bearer_before_identity() -> Result<()> { + let metadata_cases = [ + (true, true, json!({}), true), + (false, true, json!({}), false), + (true, false, json!({}), false), + ] + .into_iter() + .chain( + [ + json!({"issuer": null}), + json!({"issuer": "https://other.example"}), + json!({"token_endpoint": "https://other.example/token"}), + json!({"token_endpoint": "http://as.example/token"}), + json!({"grant_types_supported": null}), + json!({"grant_types_supported": ["authorization_code"]}), + json!({"authorization_grant_profiles_supported": ["other"]}), + json!({"token_endpoint_auth_methods_supported": ["private_key_jwt"]}), + ] + .into_iter() + .map(|invalid| (true, true, invalid, false)), + ); + let fallback_paths = [ + "/.well-known/oauth-protected-resource/enterprise", + "/enterprise/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource", + ]; + let fallback_cases = fallback_paths.into_iter().flat_map(|path| { + [true, false].map(move |valid| ("GET", path, valid, true, json!({}), valid)) + }); + for (metadata_method, metadata_path, resource_matches, issuer_matches, additions, valid) in + metadata_cases + .map(|(resource, issuer, additions, valid)| { + ( + "POST", + "/enterprise/tools", + resource, + issuer, + additions, + valid, + ) + }) + .chain(fallback_cases) + { + let server = MockServer::start().await; + let query = "tenant=one%2Ftwo&tenant=three+four&flag=&encoded=%2f"; + let resource = format!("{}/enterprise?{query}", server.uri()); + let transport = format!("{}/enterprise/tools?{query}", server.uri()); + let issuer = format!("{}/as", server.uri()); + // A query-only resource change must not be accepted as the same resource. + let advertised_resource = if resource_matches { + resource.clone() + } else { + resource.replace("one", "two") + }; + Mock::given(method(metadata_method)) + .and(path(metadata_path)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "resource": advertised_resource, + "authorization_servers": [format!("{}/unused", server.uri()), issuer], + }))) + .mount(&server) + .await; + let mut metadata = json!({"issuer": issuer, "token_endpoint": format!("{issuer}/token"), + "grant_types_supported": [JWT_BEARER_GRANT_TYPE], + "token_endpoint_auth_methods_supported": ["none"]}); + metadata + .as_object_mut() + .expect("metadata") + .extend(additions.as_object().expect("overrides").clone()); + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/as")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&server) + .await; + let polled = AtomicBool::new(false); + let mut exchange = request( + &resource, + Some(if issuer_matches { + &issuer + } else { + "https://other.example" + }), + ); + exchange.server_url = &transport; + exchange.idp_identity = Box::pin(async { + polled.store(true, Ordering::SeqCst); + bail!("identity resolver reached") + }); + exchange_ema_auth_token(exchange) + .await + .expect_err("stop before token exchange"); + assert_eq!(polled.load(Ordering::SeqCst), valid, "{additions}"); + let requests = server.received_requests().await.expect("requests"); + let resource_requests = requests + .iter() + .filter(|request| request.url.path() != "/.well-known/oauth-authorization-server/as") + .collect::>(); + assert!( + resource_requests + .iter() + .all(|request| request.url.query() == Some(query)) + ); + assert!( + resource_requests + .iter() + .all(|request| request.url.path() == "/enterprise/tools" + || (metadata_method == "GET" && fallback_paths.contains(&request.url.path()))) + ); + assert_eq!( + resource_requests + .last() + .expect("resource metadata request") + .url + .path(), + metadata_path + ); + } + Ok(()) +} + +#[tokio::test] +async fn modern_challenge_confines_metadata_redirects() -> Result<()> { + let server = MockServer::start().await; + let authorization = MockServer::start().await; + let attacker = MockServer::start().await; + let resource = format!("{}/mcp", server.uri()); + let issuer = authorization.uri(); + let prm = format!("{}/enterprise/resource", server.uri()); + Mock::given(method("POST")) + .and(path("/mcp")) + .respond_with(move |request: &wiremock::Request| { + assert_eq!(request.headers["mcp-protocol-version"], "2026-07-28"); + assert_eq!( + request.body_json::().expect("discovery")["method"], + "server/discover" + ); + ResponseTemplate::new(401).insert_header( + "www-authenticate", + format!("Bearer resource_metadata=\"{prm}\""), + ) + }) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/enterprise/resource")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"resource": resource, "authorization_servers": [issuer]})), + ) + .mount(&server) + .await; + let metadata = json!({"issuer": issuer, "token_endpoint": format!("{issuer}/token"), + "grant_types_supported": [JWT_BEARER_GRANT_TYPE], + "token_endpoint_auth_methods_supported": ["none"]}); + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(move |request: &wiremock::Request| { + assert!(!request.headers.contains_key("authorization")); + ResponseTemplate::new(200).set_body_json(metadata.clone()) + }) + .mount(&authorization) + .await; + let exchange = request(&resource, Some(&issuer)); + let (metadata, actual_resource) = + discover_ema_authorization_metadata(EmaDiscoveryRequest::from(&exchange)).await?; + assert_eq!( + (metadata.issuer, metadata.token_endpoint, actual_resource), + ( + Some(issuer.clone()), + format!("{issuer}/token"), + resource.clone() + ) + ); + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with( + ResponseTemplate::new(307) + .insert_header("location", format!("{}/capture", attacker.uri())), + ) + .with_priority(1) + .mount(&authorization) + .await; + let error = discover_ema_authorization_metadata(EmaDiscoveryRequest::from(&exchange)) + .await + .err() + .context("cross-origin redirect must fail")?; + assert!(error.to_string().contains("cross-origin redirect")); + assert!( + attacker + .received_requests() + .await + .expect("requests") + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn legacy_challenges_require_a_recognized_discovery_rejection() -> Result<()> { + for (status, id, code, message, legacy) in [ + (404, json!(null), -1, "", true), + (405, json!(null), -1, "", true), + ( + 200, + json!("ema-auth-discover"), + -32601, + "Method not found", + true, + ), + ( + 400, + json!("ema-auth-discover"), + -32022, + "Unsupported protocol version: 2026-07-28", + true, + ), + ( + 400, + json!(null), + -32000, + "Bad Request: No valid session ID provided", + true, + ), + (200, json!("unrelated"), -32601, "Method not found", false), + (400, json!(null), -32000, "Unrecognized rejection", false), + (503, json!(null), -1, "", false), + ] { + for (sse, get_status) in [ + (false, 401), + (false, 400), + (false, 404), + (false, 405), + (false, 406), + (true, 401), + (true, 405), + ] { + let initialize = get_status != 401; + let server = MockServer::start().await; + let resource = format!("{}/mcp", server.uri()); + let prm = format!("{}/custom/metadata", server.uri()); + let rejection = if code == -1 { + ResponseTemplate::new(status) + } else { + let body = json!({ + "jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}, + }); + if sse { + ResponseTemplate::new(status).set_body_raw( + format!(": keepalive\n\nevent: message\ndata: {body}\n\n"), + "text/event-stream", + ) + } else { + ResponseTemplate::new(status).set_body_json(body) + } + }; + let challenge = ResponseTemplate::new(401).insert_header( + "www-authenticate", + format!("Bearer resource_metadata=\"{prm}\""), + ); + let post_challenge = challenge.clone(); + Mock::given(method("POST")) + .and(path("/mcp")) + .respond_with(move |request: &wiremock::Request| { + let body = request.body_json::().expect("probe"); + if body["method"] == "server/discover" { + return rejection.clone(); + } + assert!(legacy && initialize); + assert_eq!(body["method"], "initialize"); + assert_eq!(body["params"]["protocolVersion"], "2025-06-18"); + assert!(!request.headers.contains_key("authorization")); + post_challenge.clone() + }) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/mcp")) + .and(wiremock::matchers::header("accept", "text/event-stream")) + .respond_with(if initialize { + ResponseTemplate::new(get_status) + } else { + challenge + }) + .expect(u64::from(legacy)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/custom/metadata")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "resource": resource, "authorization_servers": [server.uri()], + }))) + .expect(u64::from(legacy)) + .mount(&server) + .await; + let exchange = request(&resource, /*issuer*/ None); + let discovered = discover_ema_resource(EmaDiscoveryRequest::from(&exchange)).await; + assert_eq!( + discovered.is_ok(), + legacy, + "{status} {message}, sse={sse}, get_status={get_status}" + ); + server.verify().await; + } + } + Ok(()) +} + +#[test] +fn discovery_rejects_private_or_ambiguous_issuer_destinations() -> Result<()> { + let resource = Url::parse("https://mcp.example/mcp")?; + for issuer in [ + "http://issuer.example", + "https://10.0.0.1", + "https://127.0.0.1", + "https://169.254.169.254", + "https://100.64.0.1", + "https://198.19.0.1", + "https://192.0.2.1", + "https://[::ffff:192.0.2.1]", + "https://[fd00::1]", + "https://[::ffff:169.254.169.254]", + "https://metadata.google.internal", + "https://metadata.azure.internal", + "https://test.localhost", + "https://issuer.example?query=1", + "https://issuer.example#fragment", + "https://credentials@issuer.example", + ] { + assert!( + validate_ema_metadata_issuer(&resource, &Url::parse(issuer)?).is_err(), + "{issuer}" + ); + } + validate_ema_metadata_issuer(&resource, &Url::parse("https://issuer.example/tenant")?)?; + validate_ema_metadata_issuer( + &Url::parse("http://127.0.0.1/mcp")?, + &Url::parse("http://[::1]:1234")?, + )?; + Ok(()) +} diff --git a/codex-rs/rmcp-client/src/ema_claims.rs b/codex-rs/rmcp-client/src/ema_claims.rs index 886f148a32a4..61a65cc34edc 100644 --- a/codex-rs/rmcp-client/src/ema_claims.rs +++ b/codex-rs/rmcp-client/src/ema_claims.rs @@ -1,7 +1,5 @@ -//! Validate token routing and signed authorization before forwarding an ID-JAG. +//! Validate OIDC identity assertion bindings before storing enterprise credentials. -use std::collections::HashSet; -use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -13,14 +11,11 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use serde::Deserialize; use serde::de::DeserializeOwned; -use crate::ema_exchange::EmaAccessToken; - pub(crate) const ID_JAG_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id-jag"; #[derive(Deserialize)] struct JwtHeader { alg: String, - typ: Option, } #[derive(Deserialize)] @@ -30,16 +25,7 @@ enum OAuthResource { Multiple(Vec), } -impl OAuthResource { - fn is_exact(&self, expected: &str) -> bool { - match self { - Self::Single(value) => value == expected, - Self::Multiple(values) => values.as_slice() == [expected], - } - } -} - -fn signed_jwt(token: &str) -> Result<(JwtHeader, T)> { +fn signed_jwt(token: &str) -> Result { let mut parts = token.split('.'); let (Some(header), Some(payload), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) @@ -56,7 +42,7 @@ fn signed_jwt(token: &str) -> Result<(JwtHeader, T)> { } let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?) .map_err(|_| anyhow!("invalid identity assertion JWT claims"))?; - Ok((header, claims)) + Ok(claims) } #[derive(Deserialize)] @@ -73,7 +59,7 @@ pub(crate) fn oidc_identity( expected_issuer: &str, expected_audience: &str, ) -> Result { - let (_, claims): (_, OidcClaims) = signed_jwt(assertion)?; + let claims: OidcClaims = signed_jwt(assertion)?; if claims.iss != expected_issuer || claims.sub.trim().is_empty() { bail!("OIDC identity assertion issuer or subject does not match the enterprise IdP"); } @@ -96,7 +82,7 @@ pub(crate) fn oidc_identity( Ok(claims) } -pub fn validate_oidc_identity_assertion( +pub(crate) fn validate_oidc_identity_assertion( assertion: &str, expected_issuer: &str, expected_audience: &str, @@ -108,143 +94,3 @@ pub fn validate_oidc_identity_assertion( } Ok(()) } - -#[derive(Deserialize)] -struct IdJagClaims { - iss: String, - sub: String, - aud: OAuthResource, - client_id: String, - jti: String, - exp: u64, - iat: u64, - resource: OAuthResource, - scope: Option, -} - -pub(crate) struct IdJagBinding<'a> { - pub issuer: &'a str, - pub audience: &'a str, - pub client_id: &'a str, - pub resource: &'a str, - /// Empty means the scope parameter was omitted, not an empty authorization ceiling. - pub requested_scopes: &'a HashSet<&'a str>, -} - -#[derive(Deserialize)] -pub(crate) struct IdJagResponse { - pub access_token: String, - issued_token_type: String, - token_type: String, - resource: Option, - scope: Option, - refresh_token: Option, -} - -impl IdJagResponse { - pub(crate) fn validate(&self, binding: IdJagBinding<'_>) -> Result> { - if self.issued_token_type != ID_JAG_TOKEN_TYPE - || self.token_type != "N_A" - || self.refresh_token.is_some() - { - bail!("enterprise IdP returned an unsupported ID-JAG token type or refresh token"); - } - let (header, claims): (_, IdJagClaims) = signed_jwt(&self.access_token)?; - if header.typ.as_deref() != Some("oauth-id-jag+jwt") - || claims.iss != binding.issuer - || !claims.aud.is_exact(binding.audience) - || claims.client_id != binding.client_id - || claims.sub.trim().is_empty() - || claims.jti.trim().is_empty() - { - bail!("ID-JAG type, issuer, audience, client, subject, or JWT ID is invalid"); - } - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - if claims.exp <= now || claims.iat > now.saturating_add(60) { - bail!("enterprise IdP returned an expired or future-issued ID-JAG"); - } - if !claims.resource.is_exact(binding.resource) - || self - .resource - .as_ref() - .is_some_and(|value| !value.is_exact(binding.resource)) - { - bail!("ID-JAG must authorize exactly the configured MCP resource"); - } - let granted = match claims.scope.as_deref() { - Some(scope) => parse_scope(scope)?, - None if binding.requested_scopes.is_empty() => HashSet::new(), - None => bail!("ID-JAG is missing the requested scope authorization"), - }; - if !binding.requested_scopes.is_empty() && !granted.is_subset(binding.requested_scopes) { - bail!("ID-JAG contains a scope outside the enterprise authorization request"); - } - match self.scope.as_deref() { - Some(scope) if parse_scope(scope)? != granted => { - bail!("enterprise IdP token response scope does not match the signed ID-JAG") - } - None if !binding.requested_scopes.is_empty() - && granted != *binding.requested_scopes => - { - bail!("enterprise IdP token response omitted its narrowed scope") - } - _ => {} - } - Ok(granted.into_iter().map(str::to_string).collect()) - } -} - -fn parse_scope(scope: &str) -> Result> { - let scopes = scope.split_ascii_whitespace().collect::>(); - if scopes.is_empty() || scopes.len() != scope.split_ascii_whitespace().count() { - bail!("enterprise authorization contains malformed or duplicate scopes"); - } - Ok(scopes) -} - -#[derive(Deserialize)] -pub(crate) struct McpAccessTokenResponse { - access_token: String, - token_type: String, - expires_in: Option, - resource: Option, - scope: Option, - refresh_token: Option, -} - -impl McpAccessTokenResponse { - pub(crate) fn validate( - self, - resource: &str, - id_jag_scopes: &HashSet, - ) -> Result { - if !self.token_type.eq_ignore_ascii_case("bearer") || self.access_token.trim().is_empty() { - bail!("MCP authorization server returned an invalid bearer token"); - } - if self.refresh_token.is_some() || self.expires_in == Some(0) { - bail!("MCP authorization server returned a refresh token or zero token lifetime"); - } - // The stable EMA response does not require the resource to be echoed; - // when present, it must agree with the resource bound in the ID-JAG. - if self - .resource - .as_ref() - .is_some_and(|returned| !returned.is_exact(resource)) - { - bail!("MCP access token must authorize exactly the configured MCP resource"); - } - // RFC 6749 defines an omitted scope as unchanged from the request. Here - // that authority is the scope carried by the validated ID-JAG. - if let Some(scope) = self.scope.as_deref() - && !parse_scope(scope)? - .iter() - .all(|scope| id_jag_scopes.contains(*scope)) - { - bail!("MCP authorization server granted a scope outside the ID-JAG authorization"); - } - Ok(EmaAccessToken { - access_token: self.access_token, - expires_in: self.expires_in.map(Duration::from_secs), - }) - } -} diff --git a/codex-rs/rmcp-client/src/ema_exchange.rs b/codex-rs/rmcp-client/src/ema_exchange.rs index 7b6131653a97..afe5252b116f 100644 --- a/codex-rs/rmcp-client/src/ema_exchange.rs +++ b/codex-rs/rmcp-client/src/ema_exchange.rs @@ -1,25 +1,18 @@ -//! Non-interactive ID-JAG exchange against explicitly trusted OAuth endpoints. +//! Adapt the SDK enterprise exchange to Codex HTTP routing and credential lifecycle errors. -use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use anyhow::anyhow; -use anyhow::bail; use codex_exec_server::HttpClient; -use rmcp::transport::auth::OAuthHttpRedirectPolicy; -use serde::Deserialize; -use serde::de::DeserializeOwned; +use oauth2::RefreshToken; +use rmcp::transport::auth::enterprise::EmaAuthorizationServer; +use rmcp::transport::auth::enterprise::EmaError; +use rmcp::transport::auth::enterprise::EmaExchangeRequest; +use rmcp::transport::auth::enterprise::EmaExchangeStage; use crate::ema_auth_policy::EmaAuthFailure; use crate::ema_auth_policy::EmaInvalidGrantSource; -use crate::ema_auth_policy::safe_oauth_error_code; -use crate::ema_auth_policy::validate_ema_oauth_endpoint; -use crate::ema_claims::ID_JAG_TOKEN_TYPE; -use crate::ema_claims::IdJagBinding; -use crate::ema_claims::IdJagResponse; -use crate::ema_claims::McpAccessTokenResponse; use crate::http_client_adapter::StreamableHttpRedirectMode; use crate::oauth_http_client::OAuthHttpClientAdapter; use crate::utils::build_default_headers; @@ -47,7 +40,7 @@ impl std::fmt::Debug for EmaAccessToken { /// The caller supplies trusted authorization-server metadata and an IdP credential. /// This primitive does not perform resource discovery or interactive login. -pub struct EmaIdJagExchangeRequest<'a> { +pub(crate) struct EmaIdJagExchangeRequest<'a> { pub resource: &'a str, pub scopes: &'a [String], pub mcp_client_id: &'a str, @@ -62,156 +55,70 @@ pub struct EmaIdJagExchangeRequest<'a> { } /// Exchanges an enterprise IdP credential for a resource-bound MCP bearer token. -pub async fn exchange_id_jag(request: EmaIdJagExchangeRequest<'_>) -> Result { - for (endpoint, description) in [ - (request.resource, "enterprise MCP resource"), - ( - request.authorization_server_issuer, - "MCP authorization server issuer", - ), - ( - request.authorization_server_token_endpoint, - "MCP token endpoint", - ), - (request.idp_issuer, "enterprise IdP issuer"), - (request.idp_token_endpoint, "enterprise IdP token endpoint"), - ] { - validate_ema_oauth_endpoint(endpoint, description)?; - } - if request.authorization_server_issuer == request.idp_issuer { - bail!("enterprise IdP and MCP authorization server issuers must be different for ID-JAG"); - } - if request.mcp_client_id.trim().is_empty() || request.idp_client_id.trim().is_empty() { - bail!("enterprise authorization requires the registered IdP and MCP client IDs"); - } - if request.refresh_token.trim().is_empty() { - bail!("enterprise IdP refresh token must not be empty"); - } - let requested_scopes = request - .scopes - .iter() - .map(String::as_str) - .collect::>(); - if requested_scopes.len() != request.scopes.len() - || request - .scopes - .iter() - .any(|scope| scope.is_empty() || scope.chars().any(char::is_whitespace)) - { - bail!("enterprise MCP authorization scopes must be distinct, non-empty scope tokens"); - } - let scope = (!request.scopes.is_empty()).then(|| request.scopes.join(" ")); - let mut params = vec![ - ("grant_type", TOKEN_EXCHANGE_GRANT_TYPE), - ("requested_token_type", ID_JAG_TOKEN_TYPE), - ("audience", request.authorization_server_issuer), - ("resource", request.resource), - ("subject_token", request.refresh_token.as_str()), - ( - "subject_token_type", - "urn:ietf:params:oauth:token-type:refresh_token", - ), - ]; - if let Some(scope) = scope.as_deref() { - params.push(("scope", scope)); - } - let id_jag: IdJagResponse = post_form( - &request.idp_http_client, +pub(crate) async fn exchange_id_jag( + request: EmaIdJagExchangeRequest<'_>, +) -> Result { + let idp_http = OAuthHttpClientAdapter::new_with_redirect_mode( + request.idp_http_client, + build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?, request.idp_token_endpoint, - ¶ms, - request.idp_client_id, - EmaInvalidGrantSource::EnterpriseIdentity, - "enterprise IdP ID-JAG exchange", - ) - .await?; - let granted_scopes = id_jag.validate(IdJagBinding { - issuer: request.idp_issuer, - audience: request.authorization_server_issuer, - client_id: request.mcp_client_id, - resource: request.resource, - requested_scopes: &requested_scopes, - })?; - // Only the signed assertion carries authority to the Resource AS. Repeating - // the requested resource or scopes could undo enterprise policy narrowing. - let access_token: McpAccessTokenResponse = post_form( - &request.resource_http_client, - request.authorization_server_token_endpoint, - &[ - ("grant_type", JWT_BEARER_GRANT_TYPE), - ("assertion", id_jag.access_token.as_str()), - ], - request.mcp_client_id, - EmaInvalidGrantSource::ResourceAuthorization, - "MCP JWT bearer exchange", - ) - .await?; - access_token.validate(request.resource, &granted_scopes) -} - -#[derive(Deserialize)] -struct OAuthErrorResponse { - error: Option, -} - -pub(crate) async fn post_form( - http_client: &Arc, - url: &str, - params: &[(&str, &str)], - client_id: &str, - invalid_grant_source: EmaInvalidGrantSource, - operation: &str, -) -> Result { - let client = OAuthHttpClientAdapter::new_with_redirect_mode( - Arc::clone(http_client), + /*has_configured_headers*/ false, + StreamableHttpRedirectMode::Legacy, + )?; + let resource_http = OAuthHttpClientAdapter::new_with_redirect_mode( + request.resource_http_client, build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?, - url, + request.authorization_server_token_endpoint, /*has_configured_headers*/ false, StreamableHttpRedirectMode::Legacy, )?; - let body = { - let mut form = url::form_urlencoded::Serializer::new(String::new()); - form.extend_pairs(params.iter().copied()); - form.append_pair("client_id", client_id); - form.finish().into_bytes() - }; - let builder = oauth2::http::Request::builder() - .method("POST") - .uri(url) - .header("content-type", "application/x-www-form-urlencoded") - .header("accept", "application/json"); - let response = client - .execute_request( - builder.body(body)?, - OAuthHttpRedirectPolicy::Stop, - Some(Duration::from_secs(30)), - ) - .await - .map_err(|error| anyhow!("{operation} request failed: {error}"))?; - if !response.status().is_success() { - let error = serde_json::from_slice::(response.body()).ok(); - // Provider-controlled text may reflect the submitted assertion or secret. - // Only known OAuth codes may reach callers. - let code = safe_oauth_error_code(error.as_ref().and_then(|error| error.error.as_deref())); - if code == "invalid_grant" { - return Err(anyhow::Error::new(EmaAuthFailure::InvalidGrant { - grant_source: invalid_grant_source, - }) - .context(format!( - "{operation} returned HTTP {}: invalid_grant", - response.status() - ))); - } - if code == "insufficient_user_authentication" { - return Err( - anyhow::Error::new(EmaAuthFailure::InsufficientUserAuthentication).context( - format!("{operation} returned HTTP {}: {code}", response.status()), - ), - ); + let refresh_token = RefreshToken::new(request.refresh_token); + // Discovery has already verified both public-client registrations. The SDK owns + // token validation and the two exchanges; Codex retains routing and credential policy. + let token = EmaExchangeRequest::new( + EmaAuthorizationServer::new( + request.idp_issuer, + request.idp_token_endpoint, + request.idp_client_id, + ), + EmaAuthorizationServer::new( + request.authorization_server_issuer, + request.authorization_server_token_endpoint, + request.mcp_client_id, + ), + request.resource, + &refresh_token, + ) + .with_scopes(request.scopes.iter().cloned()) + .exchange(&idp_http, &resource_http) + .await + .map_err(|error| { + let failure = match &error { + EmaError::InvalidGrant(EmaExchangeStage::IdentityProvider) => { + Some(EmaAuthFailure::InvalidGrant { + grant_source: EmaInvalidGrantSource::EnterpriseIdentity, + }) + } + EmaError::InvalidGrant(EmaExchangeStage::ResourceAuthorizationServer) => { + Some(EmaAuthFailure::InvalidGrant { + grant_source: EmaInvalidGrantSource::ResourceAuthorization, + }) + } + EmaError::InsufficientUserAuthentication(_) => { + Some(EmaAuthFailure::InsufficientUserAuthentication) + } + // Future SDK stages must not invalidate the shared enterprise credential. + _ => None, + }; + match failure { + Some(failure) => anyhow::Error::new(failure).context(error), + None => anyhow::Error::new(error), } - bail!("{operation} returned HTTP {}: {code}", response.status()); - } - serde_json::from_slice(response.body()) - .map_err(|_| anyhow!("failed to parse {operation} response")) + })?; + Ok(EmaAccessToken { + access_token: token.access_token.secret().to_owned(), + expires_in: token.expires_in, + }) } #[cfg(test)] diff --git a/codex-rs/rmcp-client/src/ema_exchange_tests.rs b/codex-rs/rmcp-client/src/ema_exchange_tests.rs index 46717907108c..1e7e1a386285 100644 --- a/codex-rs/rmcp-client/src/ema_exchange_tests.rs +++ b/codex-rs/rmcp-client/src/ema_exchange_tests.rs @@ -19,6 +19,8 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::*; +use crate::ema_auth_policy::validate_ema_oauth_endpoint; +use crate::ema_claims::ID_JAG_TOKEN_TYPE; use crate::ema_claims::validate_oidc_identity_assertion; fn http_client() -> Arc { @@ -42,7 +44,7 @@ fn unique_form_fields(body: &[u8]) -> HashMap { fn jwt(claims: &Value) -> String { format!( - "{}.{}.signature", + "{}.{}.c2lnbmF0dXJl", URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"oauth-id-jag+jwt"}"#), URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("serialize claims")) ) @@ -70,6 +72,25 @@ fn token_response() -> Value { json!({"access_token":"resource-token","token_type":"Bearer","expires_in":300}) } +async fn exchange(server: &MockServer, refresh_token: &str) -> Result { + let issuer = format!("{}/idp", server.uri()); + let audience = format!("{}/as", server.uri()); + exchange_id_jag(EmaIdJagExchangeRequest { + resource: &format!("{}/mcp", server.uri()), + scopes: &[], + mcp_client_id: "mcp-client", + authorization_server_issuer: &audience, + authorization_server_token_endpoint: &format!("{audience}/token"), + idp_token_endpoint: &format!("{issuer}/token"), + idp_issuer: &issuer, + idp_client_id: "idp-client", + refresh_token: refresh_token.to_string(), + idp_http_client: http_client(), + resource_http_client: http_client(), + }) + .await +} + #[tokio::test] async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> { let requested_scopes = ["files.read".to_string(), "files.write".to_string()]; @@ -185,168 +206,73 @@ async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> { Ok(()) } -#[test] -fn signed_claims_and_resource_tokens_cannot_widen_authority() -> Result<()> { - let requested = HashSet::from(["files.read", "files.write"]); - let original = claims( - "https://idp.example", - "https://as.example", - "https://mcp.example", - ); - let binding = || IdJagBinding { - issuer: "https://idp.example", - audience: "https://as.example", - client_id: "mcp-client", - resource: "https://mcp.example", - requested_scopes: &requested, - }; - let valid: IdJagResponse = serde_json::from_value(jag_response(&original))?; - let granted = valid.validate(binding())?; - assert_eq!(granted, HashSet::from(["files.read".to_string()])); - for (requested_scopes, signed_scope, response_scope, valid) in [ - ("", Some("files.read"), Some("files.read"), true), - ("", Some("files.read"), None, true), - ("", None, None, true), - ("files.read", Some("files.read"), None, true), - ("files.read files.write", Some("files.read"), None, false), - ( - "files.read", - Some("files.read files.write"), - Some("files.read files.write"), - false, - ), - ("files.read", None, None, false), - ("", Some("files.read"), Some("files.write"), false), - ("", Some(" \t"), None, false), - ("", Some("files.read files.read"), Some("files.read"), false), - ("", Some("files.read"), Some(" \t"), false), - ("", Some("files.read"), Some("files.read files.read"), false), +#[tokio::test] +async fn sdk_validation_rejects_unsupported_authorization_before_returning_a_bearer() { + for location in [ + "signed claims", + "IdP response", + "resource response", + "signed scope", ] { - let requested_scopes = requested_scopes.split_ascii_whitespace().collect(); - let mut scoped_claims = original.clone(); - scoped_claims - .as_object_mut() - .expect("ID-JAG claims") - .remove("scope"); - if let Some(scope) = signed_scope { - scoped_claims["scope"] = json!(scope); - } - let mut response = jag_response(&scoped_claims); - response - .as_object_mut() - .expect("ID-JAG response") - .remove("scope"); - if let Some(scope) = response_scope { - response["scope"] = json!(scope); - } - let response: IdJagResponse = serde_json::from_value(response)?; - let result = response.validate(IdJagBinding { - requested_scopes: &requested_scopes, - ..binding() - }); - assert_eq!( - result.is_ok(), - valid, - "requested {requested_scopes:?}, signed {signed_scope:?}, response {response_scope:?}" + let server = MockServer::builder().start().await; + let original = claims( + &format!("{}/idp", server.uri()), + &format!("{}/as", server.uri()), + &format!("{}/mcp", server.uri()), ); - if let Ok(granted) = result { - assert_eq!( - granted, - signed_scope - .into_iter() - .map(str::to_string) - .collect::>() - ); - for (scope, valid_token) in [ - (None, true), - (Some("files.read"), signed_scope.is_some()), - (Some("files.read files.write"), false), - ] { - let mut response = token_response(); - if let Some(scope) = scope { - response["scope"] = json!(scope); + let mut jag = jag_response(&original); + let mut bearer = token_response(); + let details = json!([{"type": "payment_initiation"}]); + match location { + "signed claims" | "signed scope" => { + let mut changed = original; + if location == "signed claims" { + changed["authorization_details"] = details; + } else { + changed["scope"] = json!("files.read\tfiles.write"); } - let response: McpAccessTokenResponse = serde_json::from_value(response)?; - assert_eq!( - response.validate("https://mcp.example", &granted).is_ok(), - valid_token, - "ID-JAG {signed_scope:?}, bearer {scope:?}" - ); + jag = jag_response(&changed); } + "IdP response" => jag["authorization_details"] = details, + "resource response" => bearer["authorization_details"] = details, + _ => unreachable!(), } - } - let mut rotated = jag_response(&original); - rotated["refresh_token"] = json!("unsupported-jag-refresh-token"); - let response: IdJagResponse = serde_json::from_value(rotated)?; - assert!(response.validate(binding()).is_err()); - for header in [ - json!({"alg": "ES256", "typ": "JWT"}), - json!({"alg": "ES256"}), - json!({"alg": "none", "typ": "oauth-id-jag+jwt"}), - ] { - let mut changed = jag_response(&original); - changed["access_token"] = json!(format!( - "{}.{}.signature", - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?), - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&original)?), - )); - let response: IdJagResponse = serde_json::from_value(changed)?; - assert!( - response.validate(binding()).is_err(), - "accepted invalid ID-JAG header {header}" - ); - } - for (field, value) in [ - ("iss", json!("https://attacker.example")), - ("aud", json!("https://attacker.example")), - ("client_id", json!("other-client")), - ("sub", json!("")), - ("jti", json!("")), - ("exp", json!(0)), - ("iat", json!(u64::MAX)), - ("scope", json!("files.admin")), - ( - "resource", - json!(["https://mcp.example", "https://other.example"]), - ), - ] { - let mut changed = original.clone(); - changed[field] = value; - let response: IdJagResponse = serde_json::from_value(jag_response(&changed))?; - assert!( - response.validate(binding()).is_err(), - "accepted changed {field}" + let resource_requested = location == "resource response"; + for (endpoint, response, count) in [ + ("/idp/token", jag, 1), + ("/as/token", bearer, u64::from(resource_requested)), + ] { + Mock::given(method("POST")) + .and(path(endpoint)) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(count) + .mount(&server) + .await; + } + let error = exchange(&server, "refresh-token") + .await + .expect_err("SDK must reject unsupported authorization"); + assert_eq!( + error.downcast_ref::(), + Some(&EmaError::InvalidResponse { + stage: if resource_requested { + EmaExchangeStage::ResourceAuthorizationServer + } else { + EmaExchangeStage::IdentityProvider + }, + message: if location == "signed scope" { + "malformed or duplicate scopes" + } else { + "authorization_details is not supported" + }, + }), + "{location}" ); - } - for (field, value) in [ - ("scope", json!("files.read files.write")), - ("scope", json!("files.read files.read")), - ("resource", json!("https://other.example")), - ("expires_in", json!(0)), - ("refresh_token", json!("refresh")), - ("token_type", json!("N_A")), - ("access_token", json!("")), - ] { - let mut changed = token_response(); - changed[field] = value; - let response: McpAccessTokenResponse = serde_json::from_value(changed)?; - assert!( - response.validate("https://mcp.example", &granted).is_err(), - "accepted changed {field}" + assert_eq!( + server.received_requests().await.expect("requests").len(), + if resource_requested { 2 } else { 1 } ); } - let mut explicit_binding = token_response(); - explicit_binding["resource"] = json!("https://mcp.example"); - explicit_binding["scope"] = json!("files.read"); - let response: McpAccessTokenResponse = serde_json::from_value(explicit_binding)?; - assert_eq!( - response.validate("https://mcp.example", &granted)?, - EmaAccessToken { - access_token: "resource-token".to_string(), - expires_in: Some(Duration::from_secs(300)), - } - ); - Ok(()) } #[test] @@ -384,72 +310,65 @@ fn identity_and_credential_destinations_are_bound() { } #[tokio::test] -async fn provider_errors_cannot_reflect_credentials() { +async fn provider_errors_preserve_credential_policy_without_reflecting_credentials() { const SENTINEL: &str = "secret-assertion-sentinel"; - for (code, expected) in [ - (SENTINEL, "OAuth token request rejected"), - ("invalid_grant", "invalid_grant"), - ( - "insufficient_user_authentication", - "insufficient_user_authentication", - ), + for (endpoint, grant_source) in [ + ("/idp/token", EmaInvalidGrantSource::EnterpriseIdentity), + ("/as/token", EmaInvalidGrantSource::ResourceAuthorization), ] { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error":code,"error_description":SENTINEL, - }))) - .mount(&server) - .await; - let error = post_form::( - &http_client(), - &format!("{}/token", server.uri()), - &[("subject_token", SENTINEL)], - "test-client", - EmaInvalidGrantSource::EnterpriseIdentity, - "test token exchange", - ) - .await - .expect_err("provider error should fail"); - match code { - "invalid_grant" => assert_eq!( - error.downcast_ref::(), - Some(&EmaAuthFailure::InvalidGrant { - grant_source: EmaInvalidGrantSource::EnterpriseIdentity, - }) - ), - "insufficient_user_authentication" => assert_eq!( - error.downcast_ref::(), - Some(&EmaAuthFailure::InsufficientUserAuthentication) - ), - _ => assert_eq!(error.downcast_ref::(), None), + for code in [ + SENTINEL, + "invalid_grant", + "insufficient_user_authentication", + "malformed", + ] { + let server = MockServer::builder().start().await; + if endpoint == "/as/token" { + let jag = jag_response(&claims( + &format!("{}/idp", server.uri()), + &format!("{}/as", server.uri()), + &format!("{}/mcp", server.uri()), + )); + Mock::given(method("POST")) + .and(path("/idp/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(jag)) + .expect(1) + .mount(&server) + .await; + } + let response = if code == "malformed" { + ResponseTemplate::new(200).set_body_json(json!({"expires_in": SENTINEL})) + } else { + ResponseTemplate::new(400).set_body_json(json!({ + "error":code,"error_description":SENTINEL, + })) + }; + Mock::given(method("POST")) + .and(path(endpoint)) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + let error = exchange(&server, SENTINEL) + .await + .expect_err("provider error should fail"); + let expected = match code { + "invalid_grant" => Some(EmaAuthFailure::InvalidGrant { grant_source }), + "insufficient_user_authentication" => { + Some(EmaAuthFailure::InsufficientUserAuthentication) + } + _ => None, + }; + assert_eq!(error.downcast_ref::(), expected.as_ref()); + assert!( + !format!("{error:#?}").contains(SENTINEL), + "provider reflected a credential" + ); + assert_eq!( + server.received_requests().await.expect("requests").len(), + if endpoint == "/as/token" { 2 } else { 1 }, + "exchange must not retry a rejected grant" + ); } - let error = error.to_string(); - assert!(!error.contains(SENTINEL), "provider reflected a credential"); - assert!(error.ends_with(expected), "{error}"); } - let server = MockServer::start().await; - let mut malformed = token_response(); - malformed["expires_in"] = json!(SENTINEL); - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(malformed)) - .mount(&server) - .await; - let error = post_form::( - &http_client(), - &format!("{}/token", server.uri()), - &[], - "test-client", - EmaInvalidGrantSource::EnterpriseIdentity, - "test token exchange", - ) - .await - .err() - .expect("malformed response should fail"); - assert!( - !format!("{error:#}").contains(SENTINEL), - "parser reflected a credential" - ); } diff --git a/codex-rs/rmcp-client/src/ema_identity.rs b/codex-rs/rmcp-client/src/ema_identity.rs index f906e553f7a0..b320c8855edb 100644 --- a/codex-rs/rmcp-client/src/ema_identity.rs +++ b/codex-rs/rmcp-client/src/ema_identity.rs @@ -38,7 +38,6 @@ pub struct EmaIdpIdentityRequest<'a> { } /// An opaque IdP refresh token whose credential lock is held through token exchange. -#[allow(dead_code)] pub struct EmaIdpIdentity { pub(crate) token_endpoint: String, pub(crate) refresh_token: String, diff --git a/codex-rs/rmcp-client/src/ema_resource.rs b/codex-rs/rmcp-client/src/ema_resource.rs new file mode 100644 index 000000000000..19ba91b2fd9a --- /dev/null +++ b/codex-rs/rmcp-client/src/ema_resource.rs @@ -0,0 +1,411 @@ +//! Bound resource discovery and its HTTP safety policy before enterprise credential use. +//! The returned client keeps one deadline across resource and authorization-server discovery. + +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use codex_exec_server::HttpClient; +use codex_network_proxy::is_non_public_ip; +use codex_protocol::mcp::ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID; +use futures::StreamExt; +use http::Method; +use http::StatusCode; +use http::header::ACCEPT; +use http::header::CONTENT_TYPE; +use http::header::LOCATION; +use http::header::WWW_AUTHENTICATE; +use rmcp::model::ClientCapabilities; +use rmcp::model::ClientJsonRpcMessage; +use rmcp::model::ClientRequest; +use rmcp::model::DiscoverRequest; +use rmcp::model::DiscoverRequestParams; +use rmcp::model::ErrorCode; +use rmcp::model::Implementation; +use rmcp::model::InitializeRequest; +use rmcp::model::InitializeRequestParams; +use rmcp::model::JsonRpcMessage; +use rmcp::model::JsonRpcRequest; +use rmcp::model::ProtocolVersion; +use rmcp::model::RequestId; +use rmcp::model::RequestMetaObject; +use rmcp::model::ServerJsonRpcMessage; +use rmcp::transport::auth::OAuthHttpRedirectPolicy; +use rmcp::transport::auth::WWWAuthenticateParams; +use rmcp::transport::common::http_header::HEADER_MCP_METHOD; +use rmcp::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; +use serde::Deserialize; +use sse_stream::SseStream; +use url::Host; +use url::Url; + +use crate::ema_auth_policy::validate_ema_auth_resource; +use crate::ema_auth_policy::validate_ema_oauth_endpoint; +use crate::http_client_adapter::StreamableHttpRedirectMode; +use crate::http_client_adapter::legacy_discovery_fallback_response; +use crate::oauth_http_client::OAuthHttpClientAdapter; +use crate::utils::build_default_headers; + +const MAX_EMA_AUTHORIZATION_SERVERS: usize = 16; +const MAX_EMA_DISCOVERY_REDIRECTS: usize = 10; +const EMA_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Deserialize)] +struct EmaResourceMetadata { + resource: String, + authorization_server: Option, + #[serde(default)] + authorization_servers: Vec, +} + +/// EMA discovery retains its own resource/issuer context while sharing the HTTP safety policy. +pub(super) struct EmaDiscoveryClient { + inner: OAuthHttpClientAdapter, + deadline: Instant, +} + +impl EmaDiscoveryClient { + pub(super) async fn get(&self, url: Url) -> Result { + self.execute( + oauth2::http::Request::builder() + .uri(url.as_str()) + .body(Vec::new())?, + ) + .await + } + + async fn post( + &self, + url: &Url, + request: &JsonRpcRequest, + version: ProtocolVersion, + ) -> Result { + self.execute( + oauth2::http::Request::builder() + .method(Method::POST) + .uri(url.as_str()) + .header(ACCEPT, "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header(HEADER_MCP_PROTOCOL_VERSION, version.as_str()) + .header(HEADER_MCP_METHOD, request.request.method()) + .body(serde_json::to_vec(request)?)?, + ) + .await + } + + async fn execute(&self, request: oauth2::HttpRequest) -> Result { + let (parts, body) = request.into_parts(); + let mut url = Url::parse(&parts.uri.to_string())?; + for _ in 0..MAX_EMA_DISCOVERY_REDIRECTS { + validate_ema_oauth_endpoint(url.as_str(), "enterprise metadata URL")?; + let remaining = self + .deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| anyhow!("enterprise metadata discovery timed out"))?; + let mut request = oauth2::http::Request::builder() + .method(parts.method.clone()) + .uri(url.as_str()) + .body(body.clone())?; + *request.headers_mut() = parts.headers.clone(); + let response = self + .inner + .execute_request(request, OAuthHttpRedirectPolicy::Stop, Some(remaining)) + .await + .map_err(|error| { + anyhow!("enterprise metadata discovery request failed: {error}") + })?; + if response.status().is_server_error() + || matches!( + response.status(), + StatusCode::REQUEST_TIMEOUT + | StatusCode::TOO_EARLY + | StatusCode::TOO_MANY_REQUESTS + ) + { + bail!( + "enterprise metadata discovery returned HTTP {}", + response.status() + ); + } + if !response.status().is_redirection() || parts.method != Method::GET { + return Ok(response); + } + let Some(location) = response.headers().get(LOCATION) else { + return Ok(response); + }; + let next = url.join(location.to_str()?)?; + if next.origin() != url.origin() { + bail!("enterprise metadata discovery refused a cross-origin redirect"); + } + url = next; + } + bail!("enterprise metadata discovery exceeded its redirect limit") + } +} + +// Discovery responses use the same JSON or SSE framing as ordinary MCP requests. +// The HTTP adapter has already bounded this body; only a terminal RPC message matters. +async fn discovery_response_message( + response: &oauth2::HttpResponse, +) -> Option { + if !response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/event-stream")) + { + return serde_json::from_slice(response.body()).ok(); + } + let mut events = + SseStream::from_bytes_stream(futures::stream::iter([Ok::<_, std::convert::Infallible>( + response.body().as_slice(), + )])); + while let Some(event) = events.next().await { + let event = event.ok()?; + if !matches!(event.event.as_deref(), None | Some("") | Some("message")) { + continue; + } + let Some(data) = event.data.filter(|data| !data.trim().is_empty()) else { + continue; + }; + let message = serde_json::from_str(&data).ok()?; + if matches!( + message, + JsonRpcMessage::Response(_) | JsonRpcMessage::Error(_) + ) { + return Some(message); + } + } + None +} + +/// Retain rmcp's denial of metadata endpoints on private addresses and cloud-metadata hosts. +pub(super) fn validate_ema_metadata_issuer(resource: &Url, issuer: &Url) -> Result<()> { + validate_ema_oauth_endpoint(issuer.as_str(), "enterprise authorization server issuer")?; + if issuer.query().is_some() { + bail!("enterprise authorization server issuer must not contain a query"); + } + let loopback = |url: &Url| match url.host() { + Some(Host::Domain(host)) => { + let host = host.trim_end_matches('.'); + host == "localhost" || host.ends_with(".localhost") + } + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + }; + if loopback(resource) && loopback(issuer) { + return Ok(()); + } + let disallowed = match issuer.host() { + Some(Host::Domain(host)) => { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + matches!( + host.as_str(), + "localhost" | "metadata" | "metadata.google.internal" | "metadata.azure.internal" + ) || host.ends_with(".localhost") + } + Some(Host::Ipv4(ip)) => is_non_public_ip(IpAddr::V4(ip)), + Some(Host::Ipv6(ip)) => is_non_public_ip(IpAddr::V6(ip)), + None => true, + }; + if disallowed { + bail!("enterprise discovery refused a private authorization-server metadata destination"); + } + Ok(()) +} + +pub(super) struct EmaDiscoveryRequest<'a> { + pub(super) server_url: &'a str, + pub(super) resource: Option<&'a str>, + pub(super) expected_issuer: Option<&'a str>, + pub(super) http_client: Arc, +} + +/// A validated resource and candidate list, with the discovery deadline still in force. +pub(super) struct EmaResource { + pub(super) server_url: Url, + pub(super) resource: String, + pub(super) authorization_servers: Vec, + pub(super) client: EmaDiscoveryClient, +} + +pub(super) async fn discover_ema_resource(request: EmaDiscoveryRequest<'_>) -> Result { + let client = EmaDiscoveryClient { + inner: OAuthHttpClientAdapter::new_with_redirect_mode( + request.http_client, + build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?, + request.server_url, + /*has_configured_headers*/ false, + StreamableHttpRedirectMode::Legacy, + )?, + deadline: Instant::now() + EMA_DISCOVERY_TIMEOUT, + }; + let server = Url::parse(request.server_url)?; + let resource = Url::parse(request.resource.unwrap_or(request.server_url))?; + let path = resource.path().trim_matches('/'); + let mut resource_urls = vec![server.clone()]; + let metadata_url = |path: &str| { + let mut url = resource.clone(); + url.set_path(path); + url + }; + if !path.is_empty() { + resource_urls.push(metadata_url(&format!( + "/.well-known/oauth-protected-resource/{path}" + ))); + resource_urls.push(metadata_url(&format!( + "/{path}/.well-known/oauth-protected-resource" + ))); + } + resource_urls.push(metadata_url("/.well-known/oauth-protected-resource")); + let mut resource_metadata = None; + for url in resource_urls { + let mut response = if url == server { + // Modern MCP servers need not serve GET. Probe a read-only RPC to receive + // their authentication challenge before resolving enterprise credentials. + let mut discover = DiscoverRequest::new(DiscoverRequestParams {}); + discover + .extensions + .insert(RequestMetaObject::with_client_context( + ProtocolVersion::V_2026_07_28, + Implementation::new("codex", env!("CARGO_PKG_VERSION")), + ClientCapabilities::builder() + .enable_extensions_with( + [( + ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID.to_string(), + Default::default(), + )] + .into(), + ) + .build(), + )); + let discover = JsonRpcRequest::new( + RequestId::String("ema-auth-discover".into()), + discover.into(), + ); + let mut response = client + .post(&server, &discover, ProtocolVersion::V_2026_07_28) + .await?; + let legacy = response.status() != StatusCode::UNAUTHORIZED + && match discovery_response_message(&response).await { + Some(message) => matches!( + legacy_discovery_fallback_response( + &ClientJsonRpcMessage::Request(discover), + message, + response.status() == StatusCode::BAD_REQUEST, + ), + JsonRpcMessage::Error(error) if error.error.code == ErrorCode::METHOD_NOT_FOUND + ), + None => matches!( + response.status(), + StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED + ), + }; + if legacy { + // Preserve legacy custom metadata challenges without exchanging credentials + // or retrying application operations. All probes share the same deadline. + response = client + .execute( + oauth2::http::Request::builder() + .uri(server.as_str()) + .header(ACCEPT, "text/event-stream") + .body(Vec::new())?, + ) + .await?; + if matches!( + response.status(), + StatusCode::BAD_REQUEST + | StatusCode::NOT_FOUND + | StatusCode::METHOD_NOT_ALLOWED + | StatusCode::NOT_ACCEPTABLE + ) { + response = client + .post( + &server, + &JsonRpcRequest::new( + RequestId::String("ema-auth-initialize".into()), + InitializeRequest::new( + InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("codex", env!("CARGO_PKG_VERSION")), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18), + ) + .into(), + ), + ProtocolVersion::V_2025_06_18, + ) + .await?; + } + } + response + } else { + client.get(url).await? + }; + if response.status() == StatusCode::UNAUTHORIZED { + let challenge_url = response + .headers() + .get_all(WWW_AUTHENTICATE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(|value| { + WWWAuthenticateParams::parse(value, &server).resource_metadata_url + }); + if let Some(url) = challenge_url { + response = client.get(url).await?; + } + } + if response.status() == StatusCode::OK + && let Ok(metadata) = serde_json::from_slice::(response.body()) + { + resource_metadata = Some(metadata); + break; + } + } + let Some(EmaResourceMetadata { + resource, + authorization_server, + authorization_servers, + }) = resource_metadata + else { + bail!("enterprise MCP protected-resource metadata is missing or invalid"); + }; + if resource.trim().is_empty() { + bail!("enterprise MCP protected-resource identifier is empty"); + } + validate_ema_auth_resource(request.server_url, Some(&resource))?; + if request + .resource + .is_some_and(|expected| expected != resource) + { + bail!( + "configured enterprise MCP resource does not match the RFC 9728 protected-resource metadata" + ); + } + let mut issuers = authorization_server + .into_iter() + .chain(authorization_servers) + .collect::>(); + if issuers.len() > MAX_EMA_AUTHORIZATION_SERVERS { + bail!( + "enterprise MCP protected-resource metadata advertises too many authorization servers" + ); + } + if let Some(expected) = request.expected_issuer { + issuers.retain(|issuer| issuer == expected); + if issuers.is_empty() { + bail!("enterprise MCP authorization server issuer does not match configured issuer"); + } + } + Ok(EmaResource { + server_url: server, + resource, + authorization_servers: issuers, + client, + }) +} diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs index bc49bd06c593..aa1239e0a443 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter.rs @@ -796,7 +796,7 @@ fn mcp_redirect_policy( // rmcp's automatic lifecycle does not yet recognize deployed legacy discovery // rejection shapes. Remove this compatibility shim once the SDK does: // https://github.com/modelcontextprotocol/rust-sdk/issues/1040 -fn legacy_discovery_fallback_response( +pub(super) fn legacy_discovery_fallback_response( request: &ClientJsonRpcMessage, response: ServerJsonRpcMessage, allow_uncorrelated_http_rejection: bool, diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 3a4a2b0f1c98..b4d65067ecc1 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -1,10 +1,12 @@ mod auth_status; mod bounded_stdio_transport; mod elicitation_client_service; +mod ema_auth; mod ema_auth_policy; mod ema_claims; mod ema_exchange; mod ema_identity; +mod ema_resource; mod enterprise_oauth_login; mod event_notification_transport; mod executor_process_transport; @@ -41,13 +43,12 @@ pub use auth_status::determine_streamable_http_auth_status; pub use auth_status::determine_streamable_http_auth_status_from_credentials; pub use auth_status::discover_streamable_http_oauth; pub use codex_protocol::protocol::McpAuthStatus; +pub use ema_auth::EmaAuthTokenExchangeRequest; +pub use ema_auth::exchange_ema_auth_token; pub use ema_auth_policy::EmaAuthFailure; pub use ema_auth_policy::EmaInvalidGrantSource; pub use ema_auth_policy::validate_ema_auth_resource; -pub use ema_claims::validate_oidc_identity_assertion; pub use ema_exchange::EmaAccessToken; -pub use ema_exchange::EmaIdJagExchangeRequest; -pub use ema_exchange::exchange_id_jag; pub use ema_identity::EmaIdpIdentity; pub use ema_identity::EmaIdpIdentityRequest; pub use ema_identity::resolve_ema_idp_identity; diff --git a/codex-rs/rollout/Cargo.toml b/codex-rs/rollout/Cargo.toml index 8981dd966269..fb2b5b39e438 100644 --- a/codex-rs/rollout/Cargo.toml +++ b/codex-rs/rollout/Cargo.toml @@ -25,6 +25,7 @@ codex-protocol = { workspace = true } codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } +memchr = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/rollout/src/reverse_jsonl_scanner.rs b/codex-rs/rollout/src/reverse_jsonl_scanner.rs index 9e0a836bee80..ed0daed295f4 100644 --- a/codex-rs/rollout/src/reverse_jsonl_scanner.rs +++ b/codex-rs/rollout/src/reverse_jsonl_scanner.rs @@ -95,7 +95,7 @@ where } let chunk = &self.chunk[..self.chunk_position]; - if let Some(newline_position) = chunk.iter().rposition(|byte| *byte == b'\n') { + if let Some(newline_position) = memchr::memrchr(b'\n', chunk) { let fragment = &chunk[newline_position + 1..]; if !self.discarding_oversized_record { if self.max_record_bytes.is_some_and(|max_record_bytes| { diff --git a/codex-rs/tui/src/app/agent_center/hints.rs b/codex-rs/tui/src/app/agent_center/hints.rs index 3a26f5474687..5a4437dab992 100644 --- a/codex-rs/tui/src/app/agent_center/hints.rs +++ b/codex-rs/tui/src/app/agent_center/hints.rs @@ -29,6 +29,7 @@ impl AgentsOverviewView { &self.agents_keymap.search, &self.agents_keymap.new_task, &self.agents_keymap.new_worktree, + &self.agents_keymap.fork, &self.agents_keymap.rename, &self.agents_keymap.stop, &self.agents_keymap.archive, @@ -82,6 +83,7 @@ impl AgentsOverviewView { "New worktree", ), ("resume", &self.agents_keymap.resume, "Resume"), + ("fork", &self.agents_keymap.fork, "Fork"), ("rename", &self.agents_keymap.rename, "Rename"), ("stop", &self.agents_keymap.stop, "Stop"), ("archive", &self.agents_keymap.archive, "Archive"), diff --git a/codex-rs/tui/src/app/agent_center_tests.rs b/codex-rs/tui/src/app/agent_center_tests.rs index 3026c160cc98..67e36e46683c 100644 --- a/codex-rs/tui/src/app/agent_center_tests.rs +++ b/codex-rs/tui/src/app/agent_center_tests.rs @@ -137,7 +137,7 @@ async fn live_center_pages_visible_rows_without_wrapping() { .lock() .unwrap() .connection_notice = Some("Reconnecting…"); - view.handle_key_event(KeyCode::Char('f').into()); + view.handle_key_event(KeyCode::Char('/').into()); view.handle_paste("Paging".into()); } screen(&view, /*width*/ 110, /*height*/ 18); @@ -185,7 +185,7 @@ async fn live_center_metadata_clips_at_grapheme_boundaries() { /*selected_thread_id*/ None, ); let input = format!("{}日本語 e\u{301} 👩\u{200d}💻", "界".repeat(/*n*/ 100_000)); - for (key, label) in [('r', "Rename › "), ('f', "Search › ")] { + for (key, label) in [('r', "Rename › "), ('/', "Search › ")] { view.handle_key_event(KeyCode::Char(key).into()); view.handle_paste(input.clone()); let rendered = screen(&view, /*width*/ 40, /*height*/ 18); @@ -340,7 +340,7 @@ async fn live_center_fixed_shortcuts_yield_to_configured_actions() { let mut app = make_test_app().await; let config: TuiKeymap = toml::from_str("[list]\ncancel = 'q'").unwrap(); app.keymap = RuntimeKeymap::from_config(&config).unwrap(); - for editor_key in ['f', 'r'] { + for editor_key in ['/', 'r'] { let mut view = app.agents_overview_view( vec![overview_thread( ThreadId::from_u128(/*value*/ 42), @@ -411,7 +411,7 @@ async fn live_center_fixed_shortcuts_yield_to_configured_actions() { .lock() .unwrap() .server_version_notice = None; - view.handle_key_event(KeyCode::Char('f').into()); + view.handle_key_event(KeyCode::Char('/').into()); assert!(!screen(&view, /*width*/ 100, /*height*/ 24).contains("enter open")); } view.handle_key_event(key); @@ -479,14 +479,16 @@ async fn live_center_search_row_appears_only_while_editing() { ["Tasks", "Status", "Updated"].map(|label| header[text.find(label).unwrap()].style()); assert_eq!(styles, [styles[0]; 3]); let idle = screen(&view, area.width, area.height); - view.handle_key_event(KeyCode::Char('f').into()); - view.handle_paste("find".into()); - insta::assert_snapshot!( - "live_center_search_active", - screen(&view, area.width, area.height) - ); - view.handle_key_event(KeyCode::Esc.into()); - assert_eq!(screen(&view, area.width, area.height), idle); + for key in [KeyCode::Char('/'), KeyCode::F(3)] { + view.handle_key_event(key.into()); + view.handle_paste("find".into()); + insta::assert_snapshot!( + "live_center_search_active", + screen(&view, area.width, area.height) + ); + view.handle_key_event(KeyCode::Esc.into()); + assert_eq!(screen(&view, area.width, area.height), idle); + } } #[tokio::test] @@ -503,7 +505,7 @@ async fn backspace_edits_search_and_rename_without_deleting_tasks() { )], /*selected_thread_id*/ None, ); - for key in ['f', 'r'] { + for key in ['/', 'r'] { view.handle_key_event(KeyCode::Char(key).into()); let initial = screen(&view, /*width*/ 88, /*height*/ 16); view.handle_paste("!".into()); @@ -583,7 +585,7 @@ async fn live_center_show_more_navigation_search_and_loading() { state.loading = false; state.refresh_failed = true; } - view.handle_key_event(KeyCode::Char('f').into()); + view.handle_key_event(KeyCode::Char('/').into()); view.handle_paste("Older task".into()); insta::assert_snapshot!( "live_center_show_more_search_retry", diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 89db4ec2dd4e..f7639dd86b1c 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -59,6 +59,8 @@ pub(super) struct AgentsOverviewState { Arc>, /// Explicit permission-profile choices for new-session carryover, retained across navigation. pub(super) selected_permission_profiles: HashMap, + /// Accepted menu requests; unchanged settings may never produce a notification. + pub(super) requested_permission_profiles: HashMap, /// Keep new tasks subscribed and reusable until a first turn makes them resumable. pub(super) blank_sessions: HashMap, pub(super) input_states: HashMap, @@ -860,7 +862,7 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } } - if !read_only && !is_new_session { + if !read_only && !is_new_session && !self.chat_widget.fork_in_progress { self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id) .await; } @@ -956,6 +958,15 @@ impl App { .active_permission_profile .as_ref() .is_some_and(|active| !active.id.starts_with(':')) + && self.chat_widget.thread_id().is_none_or(|thread_id| { + self.agents_overview + .selected_permission_profiles + .get(&thread_id) + != profile + .active_permission_profile + .as_ref() + .map(|active| &active.id) + }) && (!profile.matches_config(&config) || config.permissions.profile_workspace_roots() != self.config.permissions.profile_workspace_roots()) diff --git a/codex-rs/tui/src/app/agents_overview_actions_tests.rs b/codex-rs/tui/src/app/agents_overview_actions_tests.rs index f7269edccce8..c0f4d992c72e 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -60,6 +60,7 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { keymap.agents.archive = Some(KeybindingsSpec::One(KeybindingSpec("f5".into()))); keymap.agents.delete = Some(KeybindingsSpec::One(KeybindingSpec("f6".into()))); keymap.agents.hide = Some(KeybindingsSpec::One(KeybindingSpec("f7".into()))); + keymap.agents.fork = Some(KeybindingsSpec::One(KeybindingSpec("f8".into()))); app.keymap = RuntimeKeymap::from_config(&keymap).unwrap(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); @@ -84,7 +85,7 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { Some(target), ); view.handle_key_event(KeyCode::Esc.into()); - view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + view.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); for character in "Target".chars() { view.handle_key_event(KeyCode::Char(character).into()); } @@ -99,6 +100,10 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { "unexpected event: {event:?}" ); } + view.handle_key_event(KeyCode::F(8).into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::ForkAgentsOverviewThread { thread_id }) if thread_id == target) + ); view.handle_key_event(KeyCode::F(7).into()); assert!( matches!(rx.try_recv(), Ok(AppEvent::HideAgentsOverviewThread { thread_id }) if thread_id == target) @@ -163,7 +168,7 @@ async fn hiding_tasks_keeps_selection_adjacent_in_display_order() -> Result<()> // Clear retained search without dismissing the command center. view.on_ctrl_c(); if filtered { - view.handle_key_event(KeyCode::Char('f').into()); + view.handle_key_event(KeyCode::Char('/').into()); view.handle_paste("Task".into()); // Search selects the first match; move back to Task 3. view.handle_key_event(KeyCode::Down.into()); @@ -753,3 +758,28 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb } Ok(()) } + +#[tokio::test] +async fn fork_shortcut_respects_metadata_editing() { + let (app, mut rx, _) = crate::app::tests::make_test_app_with_channels().await; + let target = ThreadId::new(); + let mut view = app.agents_overview_view( + vec![overview_thread( + target, + /*parent_thread_id*/ None, + "Target", + ThreadStatus::Idle, + )], + Some(target), + ); + for editor in ['r', '/'] { + view.handle_key_event(KeyCode::Char(editor).into()); + view.handle_key_event(KeyCode::Char('f').into()); + assert!(rx.try_recv().is_err()); + view.handle_key_event(KeyCode::Esc.into()); + } + view.handle_key_event(KeyCode::Char('f').into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::ForkAgentsOverviewThread { thread_id }) if thread_id == target) + ); +} diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index d422598d5a91..7f7fd04ffce3 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -121,6 +121,13 @@ impl App { return Ok(AppRunControl::Continue); }; let selected_profile = self.chat_widget.thread_id().and_then(|thread_id| { + if let Some(selection) = self + .agents_overview + .requested_permission_profiles + .get(&thread_id) + { + return Some(selection.clone()); + } let source = self.chat_widget.config_ref(); let active = source.permissions.active_permission_profile()?; (self diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index c4b6bd350263..125a1366c2bf 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -6,6 +6,7 @@ mod command_center; use super::super::agents_overview_view::AgentsOverviewGrouping; use super::*; +use crate::app::tests::make_test_app_with_channels; use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot; #[tokio::test] @@ -1263,7 +1264,7 @@ async fn shared_overview_shows_only_root_sessions() { state.lock().unwrap().grouping, AgentsOverviewGrouping::Project ); - action_view.handle_key_event(KeyCode::Char('f').into()); + action_view.handle_key_event(KeyCode::Char('/').into()); assert!(action_view.handle_paste("Repair authentication".into())); action_view.handle_key_event(KeyCode::Enter.into()); assert!( @@ -2088,7 +2089,7 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> app.chat_widget.show_bottom_pane_view(Box::new(view)); for key in [ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE), KeyCode::Esc.into(), ] { @@ -2139,7 +2140,7 @@ async fn command_center_cursor_tracks_fixed_footer() { )], /*selected_thread_id*/ None, ); - for (key, label) in [('f', "Search ›"), ('r', "Rename ›")] { + for (key, label) in [('/', "Search ›"), ('r', "Rename ›")] { view.handle_key_event(KeyCode::Esc.into()); view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); for width in [48, 96, 120] { @@ -2620,7 +2621,7 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res let mut app = make_test_app().await; let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); - view.handle_key_event(KeyCode::Char('f').into()); + view.handle_key_event(KeyCode::Char('/').into()); view.handle_paste("Keep searching".into()); app.chat_widget.show_bottom_pane_view(Box::new(view)); let before = render_bottom_popup(&app.chat_widget, /*width*/ 48); @@ -2720,6 +2721,155 @@ async fn command_center_action_failures_remain_visible() -> Result<()> { #[path = "agents_overview_actions_tests.rs"] mod actions; +#[tokio::test] +async fn overview_fork_preserves_an_open_side_conversation() -> Result<()> { + let mut app = Box::pin(make_test_app()).await; + let side = ThreadId::new(); + app.active_thread_id = Some(side); + app.side_threads.insert( + side, + super::super::side::SideThreadState::new(ThreadId::new()), + ); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let RequestId::Integer(request) = server.next_request_id() else { + panic!("integer request ID") + }; + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::ForkAgentsOverviewThread { + thread_id: ThreadId::new(), + }, + )) + .await?; + assert_eq!(app.current_displayed_thread_id(), Some(side)); + assert!(app.side_threads.contains_key(&side)); + assert!(!app.chat_widget.fork_in_progress); + assert_eq!(server.next_request_id(), RequestId::Integer(request + 1)); + insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80)); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn overview_fork_keeps_idle_source_input_queued() -> Result<()> { + for cached_primary in [false, true] { + let (app, mut events, _) = make_test_app_with_channels().await; + let mut app = Box::pin(app); + trust_fixture_folders(&mut app); + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"session\"\n", + )?; + let source = ThreadId::from_string( + &app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Source task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("saved rollout"), + )?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + if cached_primary { + Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, source)).await?; + assert_eq!(app.current_displayed_thread_id(), Some(source)); + app.store_active_thread_receiver().await; + } + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget.queue_user_message_with_options( + "Keep this on the source".into(), + crate::bottom_pane::QueuedInputAction::Plain, + Vec::new(), + ); + let input = app.chat_widget.capture_thread_input_state().unwrap(); + app.agents_overview + .input_states + .insert(source, input.clone()); + if cached_primary { + app.ensure_thread_channel(source) + .store + .lock() + .await + .input_state = Some(input); + let child = ThreadId::new(); + app.active_thread_id = Some(child); + let mut session = app + .ensure_thread_channel(source) + .store + .lock() + .await + .session + .clone() + .unwrap(); + session.thread_id = child; + app.chat_widget.handle_thread_session(session); + } + while events.try_recv().is_ok() {} + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::ForkAgentsOverviewThread { thread_id: source }, + )) + .await?; + assert_eq!( + app.current_displayed_thread_id(), + Some(source), + "{}", + render_bottom_popup(&app.chat_widget, /*width*/ 96) + ); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["Keep this on the source"] + ); + let retained = app.agents_overview.input_states[&source].clone(); + let mut ready = None; + while let Ok(event) = events.try_recv() { + assert!(!matches!( + event, + AppEvent::CodexOp(AppCommand::UserTurn { .. }) + )); + if matches!(event, AppEvent::ForkAgentsOverviewThreadReady { .. }) { + ready = Some(event); + } + } + // Failed reattachment can leave the source displayed as a saved replay. + app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, + }; + app.ensure_thread_channel(source).mark_replay_only(); + let RequestId::Integer(request) = server.next_request_id() else { + panic!("integer request ID") + }; + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::ForkAgentsOverviewThreadReady { thread_id: source }, + )) + .await?; + assert_eq!(app.current_displayed_thread_id(), Some(source)); + assert_eq!(server.next_request_id(), RequestId::Integer(request + 1)); + assert!(!app.chat_widget.fork_in_progress); + app.app_server_target = AppServerTarget::Embedded; + Box::pin(app.handle_event(&mut tui, &mut server, ready.expect("fork continuation"))) + .await?; + assert_ne!(app.current_displayed_thread_id(), Some(source)); + assert_eq!(app.agents_overview.input_states[&source], retained); + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + assert!(!app.chat_widget.fork_in_progress); + server.shutdown().await?; + } + Ok(()) +} + fn trust_fixture_folders(app: &mut App) { let projects = serde_json::json!({ test_path_buf("/").display().to_string(): {"trust_level": "trusted"}, diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index fbe2ae575a89..d8186f20223c 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -119,6 +119,9 @@ impl App { self.agents_overview.threads.insert(thread_id, Some(thread)); } ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => { + self.agents_overview + .requested_permission_profiles + .remove(&thread_id); self.agents_overview .selected_permission_profiles .remove(&thread_id); @@ -132,6 +135,9 @@ impl App { self.agents_overview.removed_threads.remove(&thread_id); } ServerNotification::ThreadClosed(_) => { + self.agents_overview + .requested_permission_profiles + .remove(&thread_id); self.agents_overview.activity.remove(&thread_id); if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) { usage.tokens = None; @@ -160,6 +166,10 @@ impl App { } ServerNotification::ThreadSettingsUpdated(settings) => { if !self.pending_server_profiles.contains_key(&thread_id) + && !self + .agents_overview + .requested_permission_profiles + .contains_key(&thread_id) && self .agents_overview .selected_permission_profiles diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 27ca540c3fb2..e3693176e1a8 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -600,6 +600,14 @@ impl BottomPaneView for AgentsOverviewView { } return; } + if self.agents_keymap.fork.is_pressed(key) { + if let Some(row) = self.selected_row() { + self.app_event_tx.send(AppEvent::ForkAgentsOverviewThread { + thread_id: row.thread_id, + }); + } + return; + } if self.agents_keymap.rename.is_pressed(key) { if let Some(row) = self.selected_row() { let mut state = self.state(); diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index cc63496e9ef9..9e5b60bd83e7 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -71,6 +71,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ThreadGoalUpdated(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ThreadPredictionUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadGoalCleared(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 00d2794e3248..d563aa2326ce 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -113,6 +113,7 @@ impl App { .await } + #[cfg(test)] pub(super) async fn rebuild_config_for_permission_profile( &self, profile_id: &str, @@ -136,6 +137,8 @@ impl App { .await } + // Local setup for startup fixtures; connected selections always use the server below. + #[cfg(test)] pub(super) async fn apply_permission_profile_selection( &mut self, selection: PermissionProfileSelection, @@ -266,25 +269,11 @@ impl App { pub(super) async fn select_permission_profile( &mut self, app_server: &mut AppServerSession, - selection: PermissionProfileSelection, + mut selection: PermissionProfileSelection, ) { if self.reject_pending_permission_change() { return; } - if (self.chat_widget.thread_id().is_none() && selection.profile_id.starts_with(':')) - || (app_server.thread_params_mode() - == crate::app_server_session::ThreadParamsMode::Embedded - && self - .config - .custom_permission_profiles - .iter() - .any(|profile| profile.id == selection.profile_id)) - { - if self.apply_permission_profile_selection(selection).await { - self.chat_widget.submit_initial_user_message_if_pending(); - } - return; - } let Some(thread_id) = self.chat_widget.thread_id() else { self.chat_widget .retain_input_after_failed_permission_selection(); @@ -293,21 +282,16 @@ impl App { ); return; }; - if !selection.profile_id.starts_with(':') - && self.chat_widget.is_user_turn_pending_or_running() - { - self.chat_widget - .retain_input_after_failed_permission_selection(); - self.chat_widget.add_error_message( - "Wait for the current turn to finish before changing permissions.".into(), - ); - return; - } let config = self.chat_widget.config_ref(); - if config - .permissions - .active_permission_profile() - .is_some_and(|profile| profile.id == selection.profile_id) + if !self + .agents_overview + .requested_permission_profiles + .contains_key(&thread_id) + && selection.profile_id.starts_with(':') + && config + .permissions + .active_permission_profile() + .is_some_and(|profile| profile.id == selection.profile_id) && selection .approval_policy .is_none_or(|policy| config.permissions.approval_policy.value() == policy.to_core()) @@ -330,10 +314,25 @@ impl App { }; match app_server.thread_settings_update(params).await { Ok(true) => { + // Omission preserves the active value on update, but uses defaults on start. + // Retain effective intent across consecutive requests before confirmation. + let previous = self + .agents_overview + .requested_permission_profiles + .get(&thread_id); + selection.approval_policy = selection + .approval_policy + .or_else(|| previous.and_then(|profile| profile.approval_policy)) + .or(Some(config.permissions.approval_policy.value().into())); + selection.approvals_reviewer = selection + .approvals_reviewer + .or_else(|| previous.and_then(|profile| profile.approvals_reviewer)) + .or(Some(config.approvals_reviewer)); self.agents_overview .selected_permission_profiles .insert(thread_id, selection.profile_id.clone()); - self.pending_server_profiles + self.agents_overview + .requested_permission_profiles .insert(thread_id, selection.clone()); self.chat_widget.add_info_message( format!( @@ -386,18 +385,35 @@ impl App { true } - pub(super) fn confirmed_server_profile( + pub(super) fn selected_server_profile( &self, thread_id: ThreadId, ) -> Option { if self.chat_widget.thread_id() != Some(thread_id) { return None; } + if let Some(selection) = self + .agents_overview + .requested_permission_profiles + .get(&thread_id) + { + return Some(selection.clone()); + } + if self.app_server_target.thread_params_mode() + == crate::app_server_session::ThreadParamsMode::Remote + { + return None; + } let config = self.chat_widget.config_ref(); let active = config.permissions.active_permission_profile()?; if active.id.starts_with(':') - || (self.app_server_target.thread_params_mode() - == crate::app_server_session::ThreadParamsMode::Embedded + || (self + .agents_overview + .selected_permission_profiles + .get(&thread_id) + != Some(&active.id) + && self.app_server_target.thread_params_mode() + == crate::app_server_session::ThreadParamsMode::Embedded && self .config .custom_permission_profiles @@ -414,6 +430,44 @@ impl App { }) } + /// Adopt authoritative widget settings before deriving overrides for subsequent turns. + pub(super) fn adopt_server_permissions(&mut self) { + self.chat_widget.set_permission_network(/*network*/ None); + self.config.permissions = self.chat_widget.config_ref().permissions.clone(); + self.config.approvals_reviewer = self.chat_widget.config_ref().approvals_reviewer; + self.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + self.config.permissions.approval_policy.value().into(), + )); + self.runtime_approvals_reviewer_override = if self + .config + .permissions + .active_permission_profile() + .is_some_and(|profile| profile.id.starts_with(':')) + { + Some(self.config.approvals_reviewer) + } else { + self.runtime_approvals_reviewer_override + .filter(|reviewer| *reviewer == self.config.approvals_reviewer) + }; + self.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&self.config)); + } + + pub(super) fn adopt_inherited_server_selection(&mut self) { + if let Some(thread_id) = self.chat_widget.thread_id() + && let Some(active) = self + .chat_widget + .config_ref() + .permissions + .active_permission_profile() + { + self.agents_overview + .selected_permission_profiles + .insert(thread_id, active.id); + } + self.adopt_server_permissions(); + } + pub(super) async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> { let mut config = self .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f8fd11d57bfd..38669630645f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -31,6 +31,19 @@ impl App { app_server: &mut AppServerSession, event: AppEvent, ) -> Result { + let from_agents_overview = matches!(event, AppEvent::ForkAgentsOverviewThreadReady { .. }); + let event = if let AppEvent::ForkAgentsOverviewThreadReady { thread_id } = event { + if self.current_displayed_thread_id() != Some(thread_id) + || (self.thread_unavailable(thread_id) + && !self.chat_widget.is_external_writer_view()) + { + self.chat_widget.fork_in_progress = false; + return Ok(AppRunControl::Continue); + } + AppEvent::ForkCurrentSession { name: None } + } else { + event + }; // Release the shortcut's input guard even when a fork is rejected below. if matches!(event, AppEvent::ForkCurrentSession { .. }) { self.chat_widget.fork_in_progress = false; @@ -496,7 +509,9 @@ impl App { } AppEvent::ForkCurrentSession { name } => { let from_locked_thread = self.chat_widget.is_external_writer_view(); - let source = if from_locked_thread { + let source = if from_agents_overview { + "agents_overview_shortcut" + } else if from_locked_thread { "locked_thread_shortcut" } else { "slash_command" @@ -533,7 +548,7 @@ impl App { fork_config.model = Some(self.chat_widget.current_model().to_string()); fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); - let selected_profile = self.confirmed_server_profile(thread_id); + let selected_profile = self.selected_server_profile(thread_id); match app_server.fork_thread_at( &self.local_settings, fork_config, @@ -574,6 +589,9 @@ impl App { .await { Ok(()) => { + if selected_profile.is_some() { + self.adopt_inherited_server_selection(); + } // Keep local input without replacing the fork's running state. self.chat_widget.restore_reconnected_input(retained_input, &[]); if let Some(err) = name_error { @@ -597,8 +615,8 @@ impl App { )); } } - if from_locked_thread { - // Repeated locked-view shortcuts must not act on the resulting view. + if from_locked_thread || from_agents_overview { + // Repeated fork shortcuts must not act on the resulting view. if let Err(err) = tui.discard_pending_input_before_interactive_screen() { tracing::warn!(%err, "failed to discard input after forking"); } @@ -612,7 +630,9 @@ impl App { } self.chat_widget.fork_in_progress = false; - self.chat_widget.maybe_send_next_queued_input(); + if !from_agents_overview { + self.chat_widget.maybe_send_next_queued_input(); + } tui.frame_requester().schedule_frame(); } AppEvent::RevertSessionForPromptEdit { @@ -2451,8 +2471,20 @@ impl App { } self.chat_widget.on_plugin_mentions_loaded(plugins); } - AppEvent::OpenRealtimeSettings => { - self.open_realtime_settings(app_server).await; + AppEvent::OpenRealtimeSettings => self.chat_widget.open_realtime_settings(), + AppEvent::OpenRealtimeSoundDevices => self.chat_widget.open_realtime_sound_devices(), + AppEvent::OpenRealtimeVoices => self.open_realtime_voices(app_server).await, + AppEvent::OpenRealtimeDevicePicker { kind } => self.list_realtime_devices(kind), + AppEvent::RealtimeDevicesListed { origin, kind, result } => { + if origin == self.active_thread_id { + match result { + Ok(devices) => self.chat_widget.open_realtime_device_picker(kind, devices), + Err(error) => self.chat_widget.add_error_message(error), + } + } + } + AppEvent::PersistRealtimeDevice { kind, name } => { + self.persist_realtime_device(kind, name).await; } AppEvent::PersistRealtimeVoiceSelection { voice } => { self.persist_realtime_voice(app_server, voice).await; @@ -2694,6 +2726,34 @@ impl App { AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), } } + AppEvent::ForkAgentsOverviewThread { thread_id } => { + if self.reconnect.offline { + return Ok(AppRunControl::Continue); + } + if !self.side_threads.is_empty() + && self.current_displayed_thread_id() != Some(thread_id) + { + self.add_agents_overview_error( + "Close the side conversation before forking another task.".into(), + ); + return Ok(AppRunControl::Continue); + } + // Attachment replay must not submit queued input on the source task. + self.chat_widget.fork_in_progress = true; + let control = self.select_agents_overview_thread(tui, app_server, thread_id).await; + if matches!(&control, Ok(AppRunControl::Continue)) + && self.current_displayed_thread_id() == Some(thread_id) + { + if let Some(input) = self.chat_widget.capture_thread_input_state() { + self.agents_overview.input_states.insert(thread_id, input); + } + self.app_event_tx.send(AppEvent::ForkAgentsOverviewThreadReady { thread_id }); + } else { + self.chat_widget.fork_in_progress = false; + } + return control; + } + AppEvent::ForkAgentsOverviewThreadReady { .. } => unreachable!("normalized above"), AppEvent::NewAgentsOverviewWorktree { cwd } => { Box::pin(self.new_agents_overview_worktree(tui, app_server, cwd)).await; } diff --git a/codex-rs/tui/src/app/realtime_settings.rs b/codex-rs/tui/src/app/realtime_settings.rs index 2f31148ee65f..92e301bd133c 100644 --- a/codex-rs/tui/src/app/realtime_settings.rs +++ b/codex-rs/tui/src/app/realtime_settings.rs @@ -1,12 +1,14 @@ -//! Resolve and persist voice preferences through the owning server before updating the UI. +//! Persist server voice preferences and machine-local audio selections before updating the UI. use super::*; +use crate::legacy_core::config::edit::ConfigEdit; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadRealtimeListVoicesParams; use codex_app_server_protocol::ThreadRealtimeListVoicesResponse; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; +use codex_realtime_webrtc::AudioDeviceKind; use uuid::Uuid; /// Unknown voices belong to newer servers; omit the override instead of blocking audio. @@ -29,6 +31,111 @@ fn configured_voice( } impl App { + pub(super) async fn persist_realtime_device( + &mut self, + kind: AudioDeviceKind, + name: Option, + ) { + let key = match kind { + AudioDeviceKind::Input => "microphone", + AudioDeviceKind::Output => "speaker", + }; + self.persist_realtime_audio(key, name.map(Into::into)).await; + self.list_realtime_devices(kind); + } + + async fn persist_realtime_audio(&mut self, key: &str, value: Option) { + // Audio runs on the TUI's machine, even when the app server is remote. + let segments = vec!["audio".to_string(), key.to_string()]; + let edits = vec![match &value { + Some(toml::Value::String(value)) => ConfigEdit::SetPath { + segments, + value: value.clone().into(), + }, + Some(_) => unreachable!("audio device settings are names"), + None => ConfigEdit::ClearPath { segments }, + }]; + if let Err(error) = + ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) + .with_edits(edits) + .apply() + .await + { + self.chat_widget + .add_error_message(format!("Failed to save audio setting: {error}")); + return; + } + match crate::legacy_core::config::load_config_toml_with_layer_stack( + self.local_settings.codex_home.as_path(), + /*cwd*/ None, + self.cli_kv_overrides.clone(), + codex_config::ConfigLoadOptions { + loader_overrides: self.loader_overrides.clone(), + cloud_config_bundle: self.cloud_config_bundle.clone(), + ..Default::default() + }, + ) + .await + { + Ok(config) => { + self.config.config_layer_stack = self + .config + .config_layer_stack + .with_user_layer_from(&config.config_layer_stack); + let audio = config.config_toml.audio.unwrap_or_default(); + let effective = toml::Value::try_from(&audio) + .ok() + .and_then(|audio| audio.get(key).cloned()); + self.local_settings.audio = Ok(audio); + self.chat_widget.local_settings.audio = self.local_settings.audio.clone(); + if effective == value { + self.chat_widget.add_info_message( + "Audio setting saved. Applies to your next voice conversation.".to_string(), + /*hint*/ None, + ); + } else { + self.chat_widget.add_error_message( + "Audio setting was saved but is overridden by another configuration layer." + .to_string(), + ); + } + } + Err(error) => self.chat_widget.add_error_message(format!( + "Audio setting was saved, but effective settings could not be read: {error}" + )), + } + } + + pub(super) fn list_realtime_devices(&self, kind: AudioDeviceKind) { + let tx = self.app_event_tx.clone(); + let origin = self.active_thread_id; + tokio::spawn(async move { + let result = async { + let package = codex_install_context::InstallContext::current() + .package_layout + .as_ref() + .ok_or_else(|| anyhow::anyhow!("voice package unavailable"))?; + let mut host = codex_realtime_webrtc::VoiceHost::connect( + package, + codex_build_info::BuildInfo::get().build_commit(), + ) + .await?; + let result = host.list_devices(kind).await; + host.close().await?; + result + } + .await; + tx.send(AppEvent::RealtimeDevicesListed { + origin, + kind, + result: result.map_err(|_| { + "Could not list audio devices. Check your audio device and voice package." + .to_owned() + }), + }); + }); + } + pub(super) async fn realtime_voices( &self, app_server: &AppServerSession, @@ -63,7 +170,7 @@ impl App { .map(Option::flatten) } - pub(super) async fn open_realtime_settings(&mut self, app_server: &AppServerSession) { + pub(super) async fn open_realtime_voices(&mut self, app_server: &AppServerSession) { let voices = match self.realtime_voices(app_server).await { Ok(voices) => voices, Err(error) => { @@ -73,7 +180,7 @@ impl App { } }; match self.effective_realtime_voice(app_server, &voices).await { - Ok(voice) => self.chat_widget.open_realtime_settings(voice, voices), + Ok(voice) => self.chat_widget.open_realtime_voices(voice, voices), Err(error) => self .chat_widget .add_error_message(format!("Failed to read voice settings: {error}")), diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index a0e8f9b1a53a..0656ba6e3876 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -327,8 +327,13 @@ impl App { self.pending_app_server_requests.clear(); // The displayed task was resumed above. Keep offscreen selections pending until those // tasks can be resumed from the server too; their old confirmations cannot arrive. - let pending_displayed_profile = - displayed.and_then(|id| self.pending_server_profiles.remove(&id)); + let pending_displayed_profile = displayed.and_then(|id| { + self.pending_server_profiles.remove(&id).or_else(|| { + self.agents_overview + .requested_permission_profiles + .remove(&id) + }) + }); if let Some(selection) = &pending_displayed_profile { self.runtime_approval_policy_override = None; if selection.approvals_reviewer.is_some() { diff --git a/codex-rs/tui/src/app/safety_buffering.rs b/codex-rs/tui/src/app/safety_buffering.rs index edab48f29b8e..705a0fe0e90d 100644 --- a/codex-rs/tui/src/app/safety_buffering.rs +++ b/codex-rs/tui/src/app/safety_buffering.rs @@ -60,6 +60,8 @@ impl App { items, cwd, active_permission_profile, + approval_policy, + approvals_reviewer, model: turn_model, effort, collaboration_mode, @@ -180,7 +182,7 @@ impl App { let retry_display = ChatWidget::user_message_display_from_inputs(items); self.config = retry_config.clone(); - let selected_profile = self.confirmed_server_profile(thread_id); + let selected_profile = self.selected_server_profile(thread_id); let started = app_server .fork_thread_at( &self.local_settings, @@ -200,6 +202,9 @@ impl App { } }; let retry_thread_id = started.session.thread_id; + *approval_policy = started.session.approval_policy; + *approvals_reviewer = Some(started.session.approvals_reviewer); + *active_permission_profile = started.session.active_permission_profile.clone(); self.detach_current_thread_for_navigation(app_server, Some(retry_thread_id)) .await; @@ -216,6 +221,9 @@ impl App { return; } + if selected_profile.is_some() { + self.adopt_inherited_server_selection(); + } let failure_input_state = input_state.clone(); self.chat_widget.restore_thread_input_state( input_state, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 4723dec35306..9cc9a84a0fbd 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -490,6 +490,7 @@ impl App { /// This helper copies every known nickname/role from `AgentNavigationState` into the /// replacement widget so that replayed collab items render agent names immediately. pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + chat_widget.fork_in_progress = self.chat_widget.fork_in_progress; self.pending_right_click_paste = None; if !self.chat_widget.realtime_conversation_is_running() { self.retain_realtime_replay_state_before_replace(); @@ -757,6 +758,20 @@ impl App { self.app_event_tx .send(AppEvent::ResetTranscriptForThreadSwitch); self.replay_thread_snapshot(snapshot, resume_restored_queue); + if let Some(thread_id) = self.chat_widget.thread_id() + && let Some(active) = self + .chat_widget + .config_ref() + .permissions + .active_permission_profile() + && self + .agents_overview + .selected_permission_profiles + .get(&thread_id) + == Some(&active.id) + { + self.adopt_server_permissions(); + } if external_writer { self.chat_widget.show_external_writer_thread(); } diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index f4006ba36ae5..e55aeb8892dd 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -722,8 +722,14 @@ impl App { .await; let fork_config = self.side_fork_config(); + let selected_profile = self.selected_server_profile(parent_thread_id); match app_server - .fork_side_thread(&self.local_settings, fork_config, parent_thread_id) + .fork_side_thread( + &self.local_settings, + fork_config, + parent_thread_id, + selected_profile.as_ref(), + ) .await { Ok(forked) => { @@ -779,6 +785,9 @@ impl App { return Ok(AppRunControl::Continue); } if self.active_thread_id == Some(child_thread_id) { + if selected_profile.is_some() { + self.adopt_inherited_server_selection(); + } if let Some(user_message) = user_message.take() { let _ = self .chat_widget diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap index c7c1e2b87ed1..6792b3390b9b 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap @@ -9,10 +9,11 @@ expression: "screen(&view, 100, 24)" Navigate Tasks View k Up n New tab/shift+tab Filter - j Down o Resume f Search - enter Open z r Rename g Group - pgdn Page down x Stop - ctrl+c Quit h Hide + j Down o Resume f3 Search + enter Open f Fork g Group + pgdn Page down z r Rename + ctrl+c Quit x Stop + h Hide backspace Delete @@ -24,5 +25,4 @@ expression: "screen(&view, 100, 24)" - esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_one_column.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_one_column.snap index 992eaa37b877..b77c12819ba6 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_one_column.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_one_column.snap @@ -19,6 +19,7 @@ expression: "screen(&view, width, height)" n New w New worktree o Resume + f Fork r Rename x Stop a Archive @@ -27,10 +28,9 @@ expression: "screen(&view, width, height)" View tab/shift+tab Filter - f Search + f3 Search g Group - esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_three_columns.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_three_columns.snap index 0e3d195eccaa..7a120b8f04c2 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_three_columns.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_three_columns.snap @@ -9,11 +9,12 @@ expression: "screen(&view, width, height)" Navigate Tasks View ↑ Up n New tab/shift+tab Filter - ↓ Down w New worktree f Search + ↓ Down w New worktree f3 Search enter Open o Resume g Group - pgup Page up r Rename - pgdn Page down x Stop - ctrl+c Quit a Archive + pgup Page up f Fork + pgdn Page down r Rename + ctrl+c Quit x Stop + a Archive h Hide backspace Delete @@ -24,5 +25,4 @@ expression: "screen(&view, width, height)" - esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_two_columns.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_two_columns.snap index 293f549dbc4d..b74828772379 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_two_columns.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_help_two_columns.snap @@ -11,18 +11,18 @@ expression: "screen(&view, width, height)" ↑ Up n New ↓ Down w New worktree enter Open o Resume - pgup Page up r Rename - pgdn Page down x Stop - ctrl+c Quit a Archive + pgup Page up f Fork + pgdn Page down r Rename + ctrl+c Quit x Stop + a Archive h Hide backspace Delete View tab/shift+tab Filter - f Search + f3 Search g Group - esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_navigation_and_complete_hints.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_navigation_and_complete_hints.snap index bf6297ac4514..a51731cbb79b 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_navigation_and_complete_hints.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_navigation_and_complete_hints.snap @@ -19,10 +19,10 @@ expression: "screen(&view, width, height)" n New w New worktree o Resume + f Fork r Rename x Stop a Archive h Hide - backspace Delete … resize to see all esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_fork_preserves_an_open_side_conversation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_fork_preserves_an_open_side_conversation.snap new file mode 100644 index 000000000000..c2af2c82bdf4 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_fork_preserves_an_open_side_conversation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 2740 +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Unable to complete action + Close the side conversation before forking another task. + + +› 1. Return to command center + + enter select · esc back diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8d54e52e558b..34e83eab49ff 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2862,6 +2862,20 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() while app_event_rx.try_recv().is_ok() {} let mut tui = crate::tui::test_support::make_test_tui()?; + app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config( + app.chat_widget.config_ref(), + )); + app.select_permission_profile( + &mut app_server, + PermissionProfileSelection { + profile_id: ":read-only".into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }, + ) + .await; + let control = Box::pin(app.handle_start_side( &mut tui, &mut app_server, @@ -2903,6 +2917,53 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() } assert!(saw_thread_started); + assert_eq!( + app.config.permissions.permission_profile(), + &PermissionProfile::read_only() + ); + assert_eq!( + app.runtime_permission_profile_override, + Some(RuntimePermissionProfileOverride::from_config( + app.chat_widget.config_ref() + )) + ); + app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id) + .await?; + app.select_permission_profile( + &mut app_server, + PermissionProfileSelection { + profile_id: ":workspace".into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Workspace".into(), + }, + ) + .await; + app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) + .await?; + let settings = next_thread_settings_updated(&mut app_server, parent_thread_id).await; + app.enqueue_thread_notification( + parent_thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id) + .await?; + assert_eq!( + app.config + .permissions + .active_permission_profile() + .unwrap() + .id, + ":workspace" + ); + assert_eq!( + app.runtime_permission_profile_override, + Some(RuntimePermissionProfileOverride::from_config( + app.chat_widget.config_ref() + )) + ); + app_server.shutdown().await?; Ok(()) } @@ -3112,23 +3173,6 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> @"• Permission selection requested: server-only" ); let mut tui = crate::tui::test_support::make_test_tui()?; - app.select_permission_profile(&mut server, selection.clone()) - .await; - insta::assert_snapshot!( - next_history_message(&mut events), - @"■ Wait for permissions to update before changing permissions." - ); - app.handle_event( - &mut tui, - &mut server, - AppEvent::ForkCurrentSession { name: None }, - ) - .await?; - let _ = next_history_message(&mut events); - insta::assert_snapshot!( - next_history_message(&mut events), - @"■ Wait for permissions to update before forking." - ); app.chat_widget .restore_user_message_to_composer("use the selected profile".into()); app.chat_widget @@ -3171,6 +3215,7 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> ); let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; app.app_server_target = crate::AppServerTarget::Remote { endpoint }; + assert!(app.selected_server_profile(thread_id).is_none()); while events.try_recv().is_ok() {} app.change_working_directory(&mut tui, &mut server, home.path().abs()) .await; @@ -3206,20 +3251,24 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> .restore_user_message_to_composer("queued follow-up".into()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let selection = app - .confirmed_server_profile(thread_id) - .expect("server profile"); + let selection = PermissionProfileSelection { + profile_id: "server-only".into(), + approval_policy: None, + approvals_reviewer: None, + display_label: "server-only".into(), + }; app.select_permission_profile(&mut server, selection).await; insta::assert_snapshot!( next_history_message(&mut events), - @"■ Wait for the current turn to finish before changing permissions." + @"• Permission selection requested: server-only" ); app.handle_event(&mut tui, &mut server, AppEvent::SettingsSelectionSettled) .await?; app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( turn_completed_notification(thread_id, "second", TurnStatus::Completed), ))); - assert!(app.chat_widget.has_queued_follow_up_messages()); + assert!(!app.chat_widget.has_queued_follow_up_messages()); + let _ = next_user_turn_op(&mut ops); server.shutdown().await?; Ok(()) } @@ -9232,7 +9281,10 @@ async fn selecting_cyber_model_defaults_active_thread_to_auto_review() { .await .expect("pending permission selection should block the cyber model"); assert_eq!(app.chat_widget.current_model(), previous_model); - app.pending_server_profiles.remove(&thread_id); + let previous_selection = app.pending_server_profiles.remove(&thread_id).unwrap(); + app.agents_overview + .requested_permission_profiles + .insert(thread_id, previous_selection); app.handle_event( &mut tui, &mut app_server, @@ -9242,6 +9294,11 @@ async fn selecting_cyber_model_defaults_active_thread_to_auto_review() { .expect("model selection should succeed"); assert!(app.pending_server_profiles.contains_key(&thread_id)); + assert!( + !app.agents_overview + .requested_permission_profiles + .contains_key(&thread_id) + ); let notification = next_thread_settings_updated(&mut app_server, thread_id).await; assert_eq!( notification.thread_settings.approval_policy, diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index 8f9c54761834..de130ba8d305 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -206,7 +206,7 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re let mut tui = crate::tui::test_support::make_test_tui()?; if pending_profile { app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::User); - app.pending_server_profiles.insert( + app.agents_overview.requested_permission_profiles.insert( id, PermissionProfileSelection { profile_id: "server-only".into(), @@ -303,6 +303,7 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re ); } assert!(app.pending_server_profiles.is_empty()); + assert!(app.agents_overview.requested_permission_profiles.is_empty()); if pending_profile { assert_eq!( app.resume_permission_overrides(&app.config), diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index 0162d1e1a655..16862db0fa3b 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -23,7 +23,12 @@ async fn confirm_permission_selection( ServerNotification::ThreadSettingsUpdated(settings), ) .await?; - if !app.pending_server_profiles.contains_key(&thread_id) { + if !app.pending_server_profiles.contains_key(&thread_id) + && !app + .agents_overview + .requested_permission_profiles + .contains_key(&thread_id) + { return Ok(()); } } @@ -659,7 +664,7 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< let home = tempdir()?; std::fs::write( home.path().join("config.toml"), - "default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n", + "approval_policy = \"never\"\napprovals_reviewer = \"auto_review\"\ndefault_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n", )?; let server_config = ConfigBuilder::default() .codex_home(home.path().into()) @@ -729,16 +734,53 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "saved history"}] }))?]).await?; } - app.select_permission_profile(&mut server, selection.clone()) - .await; + // Omitted custom-profile fields must preserve the preceding accepted choice, + // even before either settings notification reaches the TUI. + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + profile_id: ":read-only".into(), + ..selection.clone() + }, + ) + .await; + // Drain without applying it: confirmation is still pending in the TUI, but + // the server cannot coalesce both updates into one latest snapshot. + let _ = next_thread_settings_updated(&mut server, thread_id).await; + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + approval_policy: None, + approvals_reviewer: None, + ..selection.clone() + }, + ) + .await; let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; - assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); - insta::assert_snapshot!( - "command_center_pending_server_permissions", - crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80) + assert_ne!(app.chat_widget.thread_id(), Some(thread_id)); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + Some(ActivePermissionProfile { + id: "server-only".into(), + extends: Some(":read-only".into()) + }), + ); + assert_eq!( + ( + app.chat_widget + .config_ref() + .permissions + .approval_policy + .value(), + app.chat_widget.config_ref().approvals_reviewer, + ), + (AskForApproval::OnRequest.to_core(), ApprovalsReviewer::User) ); let settings = next_thread_settings_updated(&mut server, thread_id).await; app.enqueue_thread_notification( @@ -786,6 +828,7 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< .remove(&app.chat_widget.thread_id().unwrap()); app.select_permission_profile(&mut server, selection.clone()) .await; + assert!(!app.reject_pending_permission_change()); } } server.shutdown().await?; @@ -1133,6 +1176,17 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br app.chat_widget .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::OpenPermissionsPopup)).await?; + tokio::time::timeout(Duration::from_secs(3), async { + while let Some(event) = events.recv().await { + let loaded = matches!(&event, AppEvent::PermissionProfilesLoaded { .. }); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + if loaded { + return Ok::<_, color_eyre::Report>(()); + } + } + color_eyre::eyre::bail!("permissions menu event stream closed") + }) + .await??; app.chat_widget.handle_key_event(KeyCode::Up.into()); app.chat_widget.handle_key_event(KeyCode::Enter.into()); while let Ok(event) = events.try_recv() { diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index b7150740b255..2cdaea45e399 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -201,12 +201,8 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); let mut tui = crate::tui::test_support::make_test_tui()?; - app.handle_tui_event( - &mut tui, - &mut session, - TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)), - ) - .await?; + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(KeyCode::F(3).into())) + .await?; app.handle_tui_event( &mut tui, &mut session, @@ -224,6 +220,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), ] { app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) .await?; @@ -240,6 +237,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< AppEvent::NewAgentsOverviewSession { .. } | AppEvent::NewAgentsOverviewWorktree { .. } | AppEvent::OpenResumePicker + | AppEvent::ForkAgentsOverviewThread { .. } )) ); assert!(app.chat_widget.has_active_view()); diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 2143be940162..95fcee0a52de 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -713,7 +713,7 @@ async fn command_center_chords_do_not_capture_search_text() -> Result<()> { let mut tui = crate::tui::test_support::make_test_tui()?; let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); - app.chat_widget.handle_key_event(KeyCode::Char('f').into()); + app.chat_widget.handle_key_event(KeyCode::Char('/').into()); for key in "new".chars() { let event = KeyCode::Char(key).into(); assert_eq!(app.route_key_chord_event(&mut tui, event), Some(event)); diff --git a/codex-rs/tui/src/app/tests/permission_selection_tests.rs b/codex-rs/tui/src/app/tests/permission_selection_tests.rs index 2c66fdfe4492..d24946fa30ba 100644 --- a/codex-rs/tui/src/app/tests/permission_selection_tests.rs +++ b/codex-rs/tui/src/app/tests/permission_selection_tests.rs @@ -11,7 +11,6 @@ async fn builtin_permission_selection_adopts_server_settings() -> Result<()> { let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await?; - let other = server.start_thread(&app.config).await?.session.thread_id; // A previously selected custom profile may have left a profile-specific proxy cached. let home = tempdir()?; std::fs::write( @@ -65,17 +64,12 @@ proxy_url = "http://127.0.0.1:43128" RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), before, ); - assert!(app.pending_server_profiles.contains_key(&thread_id)); + assert!( + app.agents_overview + .requested_permission_profiles + .contains_key(&thread_id) + ); insta::assert_snapshot!(next_history_message(&mut events).replace(profile_id, ""), @"• Permission selection requested: "); - while events.try_recv().is_ok() {} - let mut tui = crate::tui::test_support::make_test_tui()?; - app.handle_event(&mut tui, &mut server, AppEvent::NewSession { name: None }) - .await?; - assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); - insta::assert_snapshot!(next_history_message(&mut events), @"■ Wait for permissions to update before switching tasks."); - app.select_agents_overview_thread(&mut tui, &mut server, other) - .await?; - assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); let settings = next_thread_settings_updated(&mut server, thread_id).await; app.enqueue_thread_notification( thread_id, @@ -139,3 +133,251 @@ async fn matching_builtin_permission_selection_submits_pending_prompt() -> Resul server.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn custom_permission_selection_uses_server_definition_and_preserves_state_on_rejection() +-> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.ephemeral = false; + let home = tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "default_permissions = \":workspace\"\n[permissions.shared]\nextends = \":workspace\"\n", + )?; + let server_config = ConfigBuilder::default() + .codex_home(home.path().into()) + .build() + .await?; + let mut server = crate::start_embedded_app_server_for_picker(&server_config).await?; + let started = server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + let client_home = tempdir()?; + std::fs::write( + client_home.path().join("config.toml"), + "default_permissions = \":workspace\"\n[permissions.shared]\nextends = \":danger-full-access\"\n", + )?; + let client_config = ConfigBuilder::default() + .codex_home(client_home.path().into()) + .build() + .await?; + app.config.codex_home = client_config.codex_home; + app.config.custom_permission_profiles = client_config.custom_permission_profiles; + let selection = PermissionProfileSelection { + profile_id: "shared".into(), + approval_policy: Some(AskForApproval::Never), + approvals_reviewer: None, + display_label: "shared".into(), + }; + app.chat_widget.handle_server_notification( + turn_started_notification(thread_id, "turn-1"), + /*replay_kind*/ None, + ); + assert!(app.chat_widget.is_user_turn_pending_or_running()); + let before = RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()); + app.select_permission_profile(&mut server, selection.clone()) + .await; + assert_eq!( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + before + ); + let settings = next_thread_settings_updated(&mut server, thread_id).await; + let expected = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &settings.thread_settings.sandbox_policy.to_core(), + settings.thread_settings.cwd.as_path(), + ); + assert!(matches!( + settings.thread_settings.sandbox_policy, + codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { .. } + )); + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + assert_eq!( + ( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + app.chat_widget + .config_ref() + .permissions + .approval_policy + .value() + ), + (&expected, AskForApproval::Never.to_core()) + ); + std::fs::write( + home.path().join("config.toml"), + "default_permissions = \":workspace\"\n[permissions.shared]\nextends = \":read-only\"\n", + )?; + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + approval_policy: None, + ..selection.clone() + }, + ) + .await; + let settings = next_thread_settings_updated(&mut server, thread_id).await; + assert!(matches!( + settings.thread_settings.sandbox_policy, + codex_app_server_protocol::SandboxPolicy::ReadOnly { .. } + )); + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + app.chat_widget.handle_server_notification( + turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), + /*replay_kind*/ None, + ); + // Unchanged re-selection is accepted without requiring a notification. + app.select_permission_profile(&mut server, selection.clone()) + .await; + assert!(!app.reject_pending_permission_change()); + let confirmed = RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()); + let confirmed_config = app.config.clone(); + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + profile_id: "missing-on-server".into(), + ..selection + }, + ) + .await; + assert_eq!( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + confirmed + ); + assert!(!app.pending_server_profiles.contains_key(&thread_id)); + assert_eq!(app.config, confirmed_config); + assert_eq!( + app.agents_overview + .selected_permission_profiles + .get(&thread_id) + .map(String::as_str), + Some("shared") + ); + // Materialize the task so /fork exercises persisted history with the confirmed profile. + server.thread_inject_items(thread_id, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "saved history"}] + }))?]).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + app.change_working_directory(&mut tui, &mut server, client_home.path().abs()) + .await; + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + insta::assert_snapshot!(next_history_message(&mut events), @"■ Changing directories with an unconfirmed named profile is not supported."); + let side = server + .fork_side_thread( + &app.local_settings, + app.config.clone(), + thread_id, + app.agents_overview + .requested_permission_profiles + .get(&thread_id), + ) + .await?; + assert_eq!( + ( + side.session.permission_profile, + side.session.approval_policy + ), + (PermissionProfile::read_only(), AskForApproval::Never) + ); + app.cli_kv_overrides.push(( + "permissions.shared.extends".into(), + TomlValue::String(":danger-full-access".into()), + )); + // Both the accepted request and its confirmed descendant remain server-owned. + for _ in 0..2 { + let previous_thread_id = app.chat_widget.thread_id(); + app.handle_event( + &mut tui, + &mut server, + AppEvent::ForkCurrentSession { name: None }, + ) + .await?; + assert_ne!(app.chat_widget.thread_id(), previous_thread_id); + assert_eq!( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + confirmed + ); + } + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn accepted_permissions_survive_directory_change_before_notification() -> Result<()> { + for preserve_history in [false, true] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + app.config.ephemeral = false; + app.config + .permissions + .set_permission_profile(PermissionProfile::Disabled)?; + let destination = app.config.codex_home.join("destination"); + std::fs::create_dir(&destination)?; + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + &destination, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = server.start_thread(&app.config).await?; + let source = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + if preserve_history { + server.thread_inject_items(source, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "saved history"}] + }))?]).await?; + } + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + profile_id: ":read-only".into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }, + ) + .await; + assert!( + app.agents_overview + .requested_permission_profiles + .contains_key(&source), + "selection must be unconfirmed" + ); + // Destination constraints belong to the client; the server's result is authoritative. + let requirements = app.config.codex_home.join("client-requirements.toml"); + std::fs::write(&requirements, "allowed_approval_policies = [\"never\"]")?; + app.loader_overrides.system_requirements_path = Some(requirements.to_path_buf()); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.change_working_directory(&mut tui, &mut server, destination.clone()) + .await; + assert_ne!(app.chat_widget.thread_id(), Some(source)); + assert_eq!( + (&app.config.cwd, app.config.permissions.permission_profile()), + (&destination, &PermissionProfile::read_only()) + ); + assert_eq!( + app.config.permissions.approval_policy.value(), + AskForApproval::OnRequest.to_core() + ); + assert_eq!( + app.runtime_permission_profile_override, + Some(RuntimePermissionProfileOverride::from_config(&app.config)) + ); + server.shutdown().await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs b/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs index 111ff663b6da..7076d8997b46 100644 --- a/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs +++ b/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs @@ -105,7 +105,11 @@ async fn permission_shortcut_confirms_without_persisting() -> Result<()> { RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), before ); - assert!(app.pending_server_profiles.contains_key(&thread_id)); + assert!( + app.agents_overview + .requested_permission_profiles + .contains_key(&thread_id) + ); insta::assert_snapshot!( next_history_message(&mut events), @"• Permission selection requested: Read Only" diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs index 16c81ceecf88..57bcbf2ea6a5 100644 --- a/codex-rs/tui/src/app/tests/realtime_requests.rs +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -7,6 +7,7 @@ use crate::app::tests::session_lifecycle_requests::start_recording_realtime_spee use crate::app::tests::session_lifecycle_requests::start_recording_remote_app_server; use crate::chatwidget::commit_realtime_history_events; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; +use crate::local_settings::LocalSettings; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::ServerNotification; @@ -1832,7 +1833,7 @@ async fn embedded_voice_settings_follow_project_after_thread_switch() -> Result< ), ); Box::pin(app.select_agent_thread(&mut tui, &mut server, thread_id)).await?; - app.open_realtime_settings(&server).await; + app.open_realtime_voices(&server).await; let popup = crate::chatwidget::tests::helpers::render_bottom_popup( &app.chat_widget, /*width*/ 80, @@ -1872,7 +1873,7 @@ async fn remote_voice_picker_reads_server_preference() -> Result<()> { app.chat_widget .set_realtime_voice(Some(codex_protocol::protocol::RealtimeVoice::Maple)); let (server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; - app.open_realtime_settings(&server).await; + app.open_realtime_voices(&server).await; assert_eq!( recorded_params(&requests, "thread/realtime/listVoices").len(), 1 @@ -1906,7 +1907,7 @@ async fn remote_voice_catalog_success_and_failure() -> Result<()> { ) .await?; while events.try_recv().is_ok() {} - app.open_realtime_settings(&server).await; + app.open_realtime_voices(&server).await; let popup = crate::chatwidget::tests::helpers::render_bottom_popup( &app.chat_widget, /*width*/ 80, @@ -2078,3 +2079,123 @@ async fn overridden_voice_save_keeps_effective_voice() -> Result<()> { } Ok(()) } + +#[tokio::test] +async fn audio_devices_are_persisted() -> Result<()> { + use codex_realtime_webrtc::AudioDeviceKind; + let mut app = make_test_app().await; + app.persist_realtime_device(AudioDeviceKind::Output, Some("Headphones".into())) + .await; + app.persist_realtime_device(AudioDeviceKind::Input, Some("Interface".into())) + .await; + let expected = codex_config::config_toml::RealtimeAudioToml { + microphone: Some("Interface".into()), + speaker: Some("Headphones".into()), + }; + assert_eq!(app.local_settings.audio.as_ref().unwrap(), &expected); + assert_eq!(LocalSettings::from(&app.config).audio, Ok(expected)); + app.persist_realtime_device(AudioDeviceKind::Input, /*name*/ None) + .await; + app.persist_realtime_device(AudioDeviceKind::Output, /*name*/ None) + .await; + assert_eq!(app.local_settings.audio, Ok(Default::default())); + assert_eq!( + app.chat_widget.local_settings.audio, + app.local_settings.audio + ); + Ok(()) +} + +#[tokio::test] +async fn audio_device_is_machine_local_across_project_and_remote_transitions() -> Result<()> { + let mut app = make_test_app().await; + let project = tempfile::tempdir()?; + let remote = tempfile::tempdir()?; + std::fs::create_dir_all(project.path().join(".codex"))?; + std::fs::write( + project.path().join(".codex/config.toml"), + "default_permissions = \"work\"\n[audio]\nmicrophone = \"Project mic\"\n[permissions.work]\nextends = \":workspace\"\n", + )?; + crate::legacy_core::config::set_project_trust_level( + &app.config.codex_home, + project.path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + app.cli_kv_overrides = vec![("default_permissions".into(), "work".into())]; + app.config = app.rebuild_config_for_cwd(project.path().into()).await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + ThreadId::new(), + remote.path().to_path_buf(), + )); + app.persist_realtime_device( + codex_realtime_webrtc::AudioDeviceKind::Input, + Some("Machine mic".into()), + ) + .await; + app.cli_kv_overrides.clear(); + assert_eq!( + LocalSettings::from(&app.config).audio, + app.local_settings.audio + ); + for cwd in [project.path(), remote.path()] { + let config = app.rebuild_config_for_cwd(cwd.to_path_buf()).await?; + let reloaded = app.local_settings.reloaded(&config); + assert_eq!(reloaded.audio, app.chat_widget.local_settings.audio); + assert_eq!( + reloaded.audio.as_ref().unwrap().microphone.as_deref(), + Some("Machine mic") + ); + } + // A project value must not hide invalid machine audio. + std::fs::write( + app.local_settings.user_config_path.as_path(), + std::fs::read_to_string(app.local_settings.user_config_path.as_path())? + .replace("microphone = \"Machine mic\"", "microphone = 0"), + )?; + let config = app + .rebuild_config_for_cwd(project.path().to_path_buf()) + .await?; + app.chat_widget.local_settings = app.local_settings.reloaded(&config); + assert!(app.chat_widget.local_settings.audio.is_err()); + app.chat_widget.open_realtime_sound_devices(); + assert!(!app.chat_widget.has_active_view()); + + Ok(()) +} + +#[tokio::test] +async fn microphone_discovery_ignores_another_threads_completion() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.active_thread_id = Some(ThreadId::new()); + for origin in [Some(ThreadId::new()), app.active_thread_id] { + let result = Ok(vec![codex_realtime_webrtc::AudioDevice { + name: "Interface".into(), + channels: 16, + is_default: true, + }]); + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::RealtimeDevicesListed { + origin, + kind: codex_realtime_webrtc::AudioDeviceKind::Input, + result, + }, + )) + .await?; + assert_eq!( + app.chat_widget.has_active_view(), + origin == app.active_thread_id + ); + } + server.shutdown().await?; + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index ca516eda0f9b..f366341c79c5 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -40,6 +40,7 @@ const SAFETY_RETRY_THREAD_NAME: &str = "Safety retry source"; enum SafetyRetryScenario { Once, RetryTwice, + PendingPermissions, InterruptedPrevious, UnsupportedPermissions, } @@ -569,6 +570,16 @@ goals = true .enable(Feature::Goals) .expect("test config should allow goals"); + if scenario == SafetyRetryScenario::PendingPermissions { + app.config + .permissions + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::from_session_snapshot( + PermissionProfile::Disabled, + Some(ActivePermissionProfile::new(":danger-full-access")), + ), + )?; + } let mut tui = crate::tui::test_support::make_test_tui()?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let mut started = app_server.start_thread(&app.config).await?; @@ -783,6 +794,22 @@ goals = true } } + if scenario == SafetyRetryScenario::PendingPermissions { + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + ); + app.select_permission_profile( + &mut app_server, + PermissionProfileSelection { + profile_id: ":read-only".into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }, + ) + .await; + } + Box::pin(app.retry_safety_buffered_turn( &mut tui, &mut app_server, @@ -923,6 +950,15 @@ goals = true let retry_thread_id = app.chat_widget.thread_id().expect("retry thread id"); // Capture the completed retry before processing its automatic goal continuation. wait_for_turn_completed(&mut app, &mut app_server, retry_thread_id).await; + if scenario == SafetyRetryScenario::PendingPermissions { + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + &PermissionProfile::read_only() + ); + } let mut replayed_history = String::new(); while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { @@ -1165,3 +1201,14 @@ async fn safety_retry_rejects_unsupported_permissions_before_interrupting() -> R ) .await } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_preserves_unconfirmed_permissions_on_its_first_turn() -> Result<()> { + run_safety_retry( + /*previous_prompt*/ None, + /*failing_draft*/ None, + /*committed_steer*/ None, + SafetyRetryScenario::PendingPermissions, + ) + .await +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index bb74f6c17cc3..4c788dc0f7a7 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -725,7 +725,12 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> { let mut side_config = app.config.clone(); side_config.ephemeral = true; let side = server - .fork_side_thread(&app.local_settings, side_config.clone(), thread_id) + .fork_side_thread( + &app.local_settings, + side_config.clone(), + thread_id, + /*selected_profile*/ None, + ) .await?; let side_id = side.session.thread_id; app.side_threads @@ -735,7 +740,12 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> { side_config.cwd = side_config.cwd.join("failure"); assert!( server - .fork_side_thread(&app.local_settings, side_config, thread_id) + .fork_side_thread( + &app.local_settings, + side_config, + thread_id, + /*selected_profile*/ None + ) .await .is_err() ); @@ -1013,7 +1023,12 @@ async fn archive_current_thread_returns_shared_servers_to_agents() -> Result<()> let mut side_config = app.config.clone(); side_config.ephemeral = true; let side = server - .fork_side_thread(&app.local_settings, side_config, thread_id) + .fork_side_thread( + &app.local_settings, + side_config, + thread_id, + /*selected_profile*/ None, + ) .await?; let side_id = side.session.thread_id; app.side_threads @@ -2822,6 +2837,7 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { &crate::local_settings::LocalSettings::from(&side_config), side_config, paginated_thread_id, + /*selected_profile*/ None, )) .await?; diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap deleted file mode 100644 index bd7838ae7ced..000000000000 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/app/tests/background_task_defaults_tests.rs -expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)" ---- - Unable to complete action - Wait for permissions to update before starting a session. - - -› 1. Return to command center - - enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap index 7c6af54461a0..b4792ba8f45b 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap @@ -9,5 +9,6 @@ expression: popup › 1. maple (current) 2. cove 3. juniper + 4. Back enter select · esc back diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 341eaa057c79..188d971ed537 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -827,7 +827,12 @@ impl App { } if should_start_turn { let config = self.chat_widget.config_ref(); - let selected_profile = self.pending_server_profiles.get(&thread_id); + let selected_profile = + self.pending_server_profiles.get(&thread_id).or_else(|| { + self.agents_overview + .requested_permission_profiles + .get(&thread_id) + }); let selected_active = selected_profile .map(|profile| ActivePermissionProfile::new(profile.profile_id.clone())); let (turn_approval_policy, turn_approvals_reviewer) = @@ -1196,6 +1201,11 @@ impl App { if self .pending_server_profiles .get(&thread_id) + .or_else(|| { + self.agents_overview + .requested_permission_profiles + .get(&thread_id) + }) .is_some_and(|selected| { notification .thread_settings @@ -1210,7 +1220,11 @@ impl App { }) }) { - confirmed_profile = self.pending_server_profiles.remove(&thread_id); + confirmed_profile = self.pending_server_profiles.remove(&thread_id).or_else(|| { + self.agents_overview + .requested_permission_profiles + .remove(&thread_id) + }); } } let inferred_session = if let ServerNotification::ThreadStarted(started) = ¬ification @@ -1298,41 +1312,9 @@ impl App { .on_thread_settings_updated(settings.clone()); notification = None; } - if let Some(selected) = confirmed_profile { + if confirmed_profile.is_some() { if self.chat_widget.thread_id() == Some(thread_id) { - if selected.approvals_reviewer.is_some() { - self.runtime_approvals_reviewer_override = None; - } - self.runtime_permission_profile_override = None; - } - if self.chat_widget.thread_id() == Some(thread_id) - && let Some(profile) = self - .chat_widget - .config_ref() - .permissions - .active_permission_profile() - && profile.id.starts_with(':') - { - let config = self.chat_widget.config_ref(); - let network = config - .network_proxy_spec_for_active_permission_profile( - &profile, - config.permissions.permission_profile(), - ) - .unwrap_or_else(|err| { - tracing::warn!(%err, "failed to refresh local permission network settings"); - None - }); - self.chat_widget.set_permission_network(network); - self.config.permissions = self.chat_widget.config_ref().permissions.clone(); - self.config.approvals_reviewer = self.chat_widget.config_ref().approvals_reviewer; - self.runtime_approval_policy_override = - Some(RuntimeApprovalPolicyOverride::Explicit( - self.config.permissions.approval_policy.value().into(), - )); - self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); - self.runtime_permission_profile_override = - Some(RuntimePermissionProfileOverride::from_config(&self.config)); + self.adopt_server_permissions(); } self.app_event_tx.send(AppEvent::SettingsSelectionSettled); } @@ -1574,6 +1556,9 @@ impl App { let thread_id = session.thread_id; self.pending_server_profiles.remove(&thread_id); + self.agents_overview + .requested_permission_profiles + .remove(&thread_id); if self.primary_thread_id != Some(thread_id) { self.recap.reset_for_new_thread(Instant::now()); } diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 2a3eae013fab..66c7b4e64ea3 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -218,19 +218,29 @@ impl App { if !thread_settings_update_has_changes(¶ms) { return false; } - if ThreadId::from_string(¶ms.thread_id) - .ok() - .is_some_and(|thread_id| self.pending_server_profiles.contains_key(&thread_id)) - && (params.cwd.is_some() - || params.approval_policy.is_some() - || params.approvals_reviewer.is_some() - || params.sandbox_policy.is_some() - || params.permissions.is_some()) + let thread_id = ThreadId::from_string(¶ms.thread_id).ok(); + let changes_permissions = params.cwd.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox_policy.is_some() + || params.permissions.is_some(); + if changes_permissions + && thread_id.is_some_and(|id| self.pending_server_profiles.contains_key(&id)) { return false; } match app_server.thread_settings_update(params).await { - Ok(settings_updated) => settings_updated, + Ok(settings_updated) => { + if settings_updated + && changes_permissions + && let Some(thread_id) = thread_id + { + self.agents_overview + .requested_permission_profiles + .remove(&thread_id); + } + settings_updated + } Err(err) => { tracing::warn!("failed to update app-server thread settings from TUI: {err}"); self.chat_widget diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index 76187b34a36a..db28e72c92b6 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -22,6 +22,7 @@ pub(super) struct ManagedWorktreeAttach { keymap: RuntimeKeymap, cwd: AbsolutePathBuf, name_error: Option, + selected_profile: Option, } /// A /cd request awaiting a fresh event-loop iteration. @@ -177,6 +178,19 @@ impl App { "Changing directories with a named profile is not supported.", ); } + let selected_profile = self + .agents_overview + .requested_permission_profiles + .get(&thread_id) + .cloned(); + if selected_profile + .as_ref() + .is_some_and(|profile| !profile.profile_id.starts_with(':')) + { + return self.working_directory_error( + "Changing directories with an unconfirmed named profile is not supported.", + ); + } let cells = &self.transcript_cells; if cells.iter().any(|cell| cell.as_any().is::()) { return self.working_directory_error("MCP inventory is still loading."); @@ -330,6 +344,7 @@ impl App { let profile = self .runtime_permission_profile_override .as_ref() + .filter(|_| selected_profile.is_none()) .filter(|profile| { scope == RuntimePolicyOverrideScope::All || profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox @@ -356,12 +371,15 @@ impl App { { return self.working_directory_error("Permission profile cannot be preserved by /cd."); } - if let Err(error) = self.apply_runtime_policy_overrides(&mut config, scope) { + if selected_profile.is_none() + && let Err(error) = self.apply_runtime_policy_overrides(&mut config, scope) + { return self.working_directory_error(format!("{error:#}")); } let actual = config.permissions.approval_policy.value(); let approval = self .runtime_approval_policy_override + .filter(|_| selected_profile.is_none()) .filter(|value| { scope == RuntimePolicyOverrideScope::All || matches!(value, RuntimeApprovalPolicyOverride::Explicit(_)) @@ -436,7 +454,7 @@ impl App { /*last_turn_id*/ None, /*before_turn_id*/ None, DeferUntilNextTurn, - /*selected_profile*/ None, + selected_profile.as_ref(), ) .await } else { @@ -446,7 +464,7 @@ impl App { &config, /*session_start_source*/ None, /*remote_cwd_override*/ None, - /*selected_profile*/ None, + selected_profile.as_ref(), ) .await }; @@ -455,12 +473,27 @@ impl App { Err(e) => return self.working_directory_error(format!("Failed to change: {e}")), }; let session = &transitioned.session; + let permissions_match = if let Some(selected) = &selected_profile { + session + .active_permission_profile + .as_ref() + .is_some_and(|active| active.id == selected.profile_id) + && selected + .approval_policy + .is_none_or(|policy| session.approval_policy == policy) + && selected + .approvals_reviewer + .is_none_or(|reviewer| session.approvals_reviewer == reviewer) + } else { + session.approval_policy.to_core() == config.permissions.approval_policy.value() + && session.approvals_reviewer == config.approvals_reviewer + && session.active_permission_profile + == config.permissions.active_permission_profile() + }; if session.thread_id == thread_id || crate::session_resume::cwds_differ(session.cwd.as_path(), cwd.as_path()) || session.runtime_workspace_roots != config.workspace_roots - || session.approval_policy.to_core() != config.permissions.approval_policy.value() - || session.approvals_reviewer != config.approvals_reviewer - || session.active_permission_profile != config.permissions.active_permission_profile() + || !permissions_match { if session.thread_id != thread_id { let _ = app_server.thread_unsubscribe(session.thread_id).await; @@ -520,6 +553,7 @@ impl App { keymap, cwd, name_error, + selected_profile, }; if managed_worktree.is_some() { // Let the large synchronous ChatWidget constructor run on a fresh event-loop stack. @@ -544,6 +578,7 @@ impl App { keymap, cwd, name_error, + selected_profile, } = attach; self.local_settings = local_settings; self.refresh_server_version_overview_notice(CODEX_CLI_VERSION); @@ -560,6 +595,9 @@ impl App { if let Err(error) = attach_widget(self, tui, started, lineage, message).await { return self.working_directory_error(format!("Could not restore session: {error}")); } + if selected_profile.is_some() { + self.adopt_inherited_server_selection(); + } if matches!( self.runtime_approval_policy_override, Some(RuntimeApprovalPolicyOverride::Restored(_)) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9e3a2beb7545..c77c55841d79 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -306,6 +306,14 @@ pub(crate) enum AppEvent { cwd: Option, }, AgentsOverviewWorktreeCreated(Result), + /// Fork the selected dashboard conversation and open the new session. + ForkAgentsOverviewThread { + thread_id: ThreadId, + }, + /// Run the existing fork action after selection events have been processed. + ForkAgentsOverviewThreadReady { + thread_id: ThreadId, + }, /// Rename a task directly from the shared dashboard. RenameAgentsOverviewThread { thread_id: ThreadId, @@ -1220,6 +1228,20 @@ pub(crate) enum AppEvent { /// Read the owning server preference before showing the voice picker. OpenRealtimeSettings, + OpenRealtimeSoundDevices, + OpenRealtimeVoices, + OpenRealtimeDevicePicker { + kind: codex_realtime_webrtc::AudioDeviceKind, + }, + RealtimeDevicesListed { + origin: Option, + kind: codex_realtime_webrtc::AudioDeviceKind, + result: Result, String>, + }, + PersistRealtimeDevice { + kind: codex_realtime_webrtc::AudioDeviceKind, + name: Option, + }, /// Save the voice for subsequent conversations through the app server. PersistRealtimeVoiceSelection { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f45e2b12640b..f9c6f5fd7793 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -793,6 +793,13 @@ impl AppServerSession { if history_support == ThreadHistorySupport::LegacyOnly { self.history_support = ThreadHistorySupport::LegacyOnly; } + // An explicit server selection must not reuse the client's local display policy. + let selected_permissions = selected_profile.map(|_| { + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &response.sandbox.to_core(), + response.cwd.as_path(), + ) + }); let mut started = started_thread_from_start_response( response, local_settings, @@ -800,6 +807,9 @@ impl AppServerSession { self.thread_params_mode(), ) .await?; + if let Some(permissions) = selected_permissions { + started.session.permission_profile = permissions; + } started.task_tools_available = task_tools_available; if task_tools_available { self.remember_task_tool_thread(started.session.thread_id); @@ -879,6 +889,7 @@ impl AppServerSession { local_settings: &LocalSettings, config: Config, thread_id: ThreadId, + selected_profile: Option<&PermissionProfileSelection>, ) -> Result { self.fork_thread_at_with_presentation( local_settings, @@ -888,7 +899,7 @@ impl AppServerSession { /*before_turn_id*/ None, ForkGoalContinuation::StartIfIdle, ForkPresentation::SideConversation, - /*selected_profile*/ None, + selected_profile, ForkPermissionMode::InheritSaved, ForkConfigSource::Session, ) @@ -957,6 +968,7 @@ impl AppServerSession { } if self.thread_params_mode() == ThreadParamsMode::Remote && permission_mode == ForkPermissionMode::InheritSaved + && selected_profile.is_none() { params.approval_policy = None; params.approvals_reviewer = None; @@ -1022,6 +1034,13 @@ impl AppServerSession { "preserving the created fork after bounded history hydration failed" ); } + // Explicit selections use the server's effective policy, including on local daemons. + let selected_permissions = selected_profile.map(|_| { + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &response.sandbox.to_core(), + response.cwd.as_path(), + ) + }); let mut started = started_thread_from_fork_response( response, local_settings, @@ -1029,6 +1048,9 @@ impl AppServerSession { self.thread_params_mode(), ) .await?; + if let Some(permissions) = selected_permissions { + started.session.permission_profile = permissions; + } started.session.fork_parent_title = fork_parent.and_then(|thread| thread.name); if self.task_tools_available(thread_id) { started.task_tools_available = true; @@ -3660,6 +3682,7 @@ mod tests { &LocalSettings::from(&ephemeral_config), ephemeral_config, source_thread_id, + /*selected_profile*/ None, ) .await?; @@ -3786,6 +3809,7 @@ mod tests { &LocalSettings::from(&side_config), side_config, source_thread_id, + /*selected_profile*/ None, ) .await?; @@ -3917,7 +3941,12 @@ mod tests { .fork_thread(&LocalSettings::from(&config), config.clone(), thread_id) .await?; let side = app_server - .fork_side_thread(&LocalSettings::from(&config), config, thread_id) + .fork_side_thread( + &LocalSettings::from(&config), + config, + thread_id, + /*selected_profile*/ None, + ) .await?; assert_eq!(regular.turns.len(), 1); diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs index 0a6cd18c9279..ecce710de8a6 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -130,13 +130,7 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override ), ); } - // A locally remembered profile may have been removed since selection. - let stale_selection = crate::app_event::PermissionProfileSelection { - profile_id: "removed-profile".into(), - approval_policy: None, - approvals_reviewer: None, - display_label: "removed-profile".into(), - }; + // With no new selection, remote forks retain the saved profile. let forked = server .fork_thread_at( &local_settings, @@ -145,7 +139,7 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override /*last_turn_id*/ None, /*before_turn_id*/ None, ForkGoalContinuation::StartIfIdle, - Some(&stale_selection), + /*selected_profile*/ None, ) .await?; assert_eq!( @@ -165,7 +159,12 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override codex_protocol::config_types::ApprovalsReviewer::AutoReview ); let side = server - .fork_side_thread(&local_settings, client_config, thread_id) + .fork_side_thread( + &local_settings, + client_config, + thread_id, + /*selected_profile*/ None, + ) .await?; assert_eq!( side.session.active_permission_profile.unwrap().id, diff --git a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs index ee2dcef069d8..644a0a3f57fe 100644 --- a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs +++ b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs @@ -150,7 +150,12 @@ stream_max_retries = 0 .await?; assert_eq!(forked.session.runtime_workspace_roots, expected_roots); let side = app_server - .fork_side_thread(&local_settings, session_config, thread_id) + .fork_side_thread( + &local_settings, + session_config, + thread_id, + /*selected_profile*/ None, + ) .await?; assert_eq!(side.session.runtime_workspace_roots, expected_roots); diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index b4d45daf13d4..7b841baf6479 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -235,6 +235,7 @@ impl ChatWidget { /// If idle and there are queued inputs, submit exactly one to start the next turn. pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { if !self.is_session_configured() + || self.fork_in_progress || self.has_misalignment_policy_violation() || self.input_queue.suppress_queue_autosend || self.input_queue.rate_limit_recovery_pending diff --git a/codex-rs/tui/src/chatwidget/permission_discovery.rs b/codex-rs/tui/src/chatwidget/permission_discovery.rs index 09f7f418d5e3..42ee1b5fa1b8 100644 --- a/codex-rs/tui/src/chatwidget/permission_discovery.rs +++ b/codex-rs/tui/src/chatwidget/permission_discovery.rs @@ -56,9 +56,6 @@ impl ChatWidget { return; } match result { - Ok(discovery) if !discovery.explicit_profile_mode => { - self.open_legacy_permissions_popup() - } Ok(discovery) => { self.permission_profiles_menu_opened = true; self.open_permission_profiles_popup(discovery); diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index fc98703f7e7c..6b4ee9332943 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -17,7 +17,8 @@ impl ChatWidget { /// Open a popup to choose the permissions mode. pub(crate) fn open_permissions_popup(&mut self) { - if self.config.explicit_permission_profile_mode + if self.thread_id.is_some() + || self.config.explicit_permission_profile_mode || self.permission_profiles_menu_opened || self .config diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index ed653846f097..5ebd1dbf7c23 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -215,8 +215,17 @@ impl ChatWidget { SelectionItem { name: label.to_string(), description: Some(description), - is_current: active_profile_id.as_deref() == Some(id) - && current_approval == approval_policy + is_current: active_profile_id.as_deref().map_or_else( + || { + Self::preset_matches_current( + current_approval, + self.config.permissions.permission_profile(), + self.config.cwd.as_path(), + preset, + ) + }, + |active| active == id, + ) && current_approval == approval_policy && current_reviewer == approvals_reviewer, actions: self.permission_mode_actions( preset, @@ -226,9 +235,11 @@ impl ChatWidget { /*return_to_permissions*/ true, ), dismiss_on_select: true, - disabled_reason: discovery - .disabled_reason(id, Some(approval_policy), Some(approvals_reviewer.into())) - .or_else(|| self.permission_mode_disabled_reason(preset, approval_policy)), + disabled_reason: discovery.disabled_reason( + id, + Some(approval_policy), + Some(approvals_reviewer.into()), + ), ..Default::default() } } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 51ac5f09b37f..d4aa343b7511 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -361,6 +361,7 @@ impl ChatWidget { | ServerNotification::ThreadStatusChanged(_) | ServerNotification::ThreadReverted(_) | ServerNotification::ThreadQueueChanged(_) + | ServerNotification::ThreadPredictionUpdated(_) | ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) | ServerNotification::ThreadUnarchived(_) diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index ccda3290f413..95e4c90cfba2 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -312,6 +312,9 @@ impl ChatWidget { } fn start_realtime_conversation(&mut self, thread_id: ThreadId) { + let Some(audio) = self.realtime_audio_settings() else { + return; + }; self.realtime_conversation.recover_late_transcripts = false; self.realtime_conversation.attempt_id = NEXT_REALTIME_ATTEMPT_ID.fetch_add(1, Ordering::Relaxed); @@ -322,9 +325,13 @@ impl ChatWidget { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.update_realtime_footer(); let app_event_tx = self.app_event_tx.clone(); + let selection = codex_realtime_webrtc::AudioDeviceSelection { + microphone: audio.microphone, + speaker: audio.speaker, + }; std::thread::spawn(move || { - let result = - RealtimeWebrtcSession::start(abort_registration).map_err(|error| error.to_string()); + let result = RealtimeWebrtcSession::start(abort_registration, selection) + .map_err(|error| error.to_string()); app_event_tx.send(AppEvent::RealtimeWebrtcOfferCreated { thread_id, attempt_id, diff --git a/codex-rs/tui/src/chatwidget/realtime_settings.rs b/codex-rs/tui/src/chatwidget/realtime_settings.rs index 3057764af5a7..e0ff65679135 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings.rs @@ -1,17 +1,62 @@ -//! Voice selection for subsequent conversations; saving never interrupts live audio. +//! Voice settings hierarchy with parent navigation; selections apply to subsequent conversations. use super::*; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; +use codex_realtime_webrtc::AudioDevice; +use codex_realtime_webrtc::AudioDeviceKind; + +fn back_item(parent: fn() -> AppEvent) -> SelectionItem { + SelectionItem { + name: "Back".to_string(), + actions: vec![Box::new(move |tx| tx.send(parent()))], + dismiss_on_select: true, + ..Default::default() + } +} impl ChatWidget { - pub(crate) fn open_realtime_settings( + pub(super) fn realtime_audio_settings( + &mut self, + ) -> Option { + match self.local_settings.audio.clone() { + Ok(audio) => Some(audio), + Err(error) => { + self.add_error_message(error); + None + } + } + } + + pub(crate) fn open_realtime_settings(&mut self) { + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Voice settings".to_string()), + items: vec![ + SelectionItem { + name: "Set sound devices".to_string(), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenRealtimeSoundDevices))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Choose a voice".to_string(), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenRealtimeVoices))], + dismiss_on_select: true, + ..Default::default() + }, + ], + footer_hint: Some(standard_popup_hint_line()), + ..SelectionViewParams::picker() + }); + } + + pub(crate) fn open_realtime_voices( &mut self, current: Option, voices: RealtimeVoicesList, ) { // The TUI uses V3, which shares the V1 voice catalog. - let items = voices + let mut items: Vec = voices .v1 .into_iter() .map(|voice| SelectionItem { @@ -24,11 +69,101 @@ impl ChatWidget { ..Default::default() }) .collect(); + items.push(back_item(|| AppEvent::OpenRealtimeSettings)); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select voice".to_string()), subtitle: Some("Applies to your next voice conversation.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenRealtimeSettings))), + ..SelectionViewParams::picker() + }); + } + + pub(crate) fn open_realtime_sound_devices(&mut self) { + let Some(audio) = self.realtime_audio_settings() else { + return; + }; + let mut items = Vec::new(); + for (kind, label, selected) in [ + (AudioDeviceKind::Input, "Input device", audio.microphone), + (AudioDeviceKind::Output, "Output device", audio.speaker), + ] { + items.push(SelectionItem { + name: label.into(), + description: Some(selected.unwrap_or_else(|| "System default".into())), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRealtimeDevicePicker { kind }) + })], + dismiss_on_select: true, + ..Default::default() + }); + } + items.push(back_item(|| AppEvent::OpenRealtimeSettings)); + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Sound devices".to_string()), + subtitle: Some("Applies to your next voice conversation.".into()), + footer_hint: Some(standard_popup_hint_line()), + items, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenRealtimeSettings))), + ..SelectionViewParams::picker() + }); + } + + pub(crate) fn open_realtime_device_picker( + &mut self, + kind: AudioDeviceKind, + devices: Vec, + ) { + let Some(audio) = self.realtime_audio_settings() else { + return; + }; + let (title, current) = match kind { + AudioDeviceKind::Input => ("Input device", audio.microphone), + AudioDeviceKind::Output => ("Output device", audio.speaker), + }; + let ambiguous: std::collections::HashSet<_> = devices + .iter() + .filter(|device| { + devices + .iter() + .filter(|other| other.name == device.name) + .count() + > 1 + }) + .map(|device| device.name.clone()) + .collect(); + let mut items: Vec = std::iter::once(/*value*/ None) + .chain(devices.into_iter().map(Some)) + .map(|device| { + let name = device.map(|device| device.name); + let is_current = name == current; + let disabled_reason = name + .as_ref() + .filter(|name| ambiguous.contains(*name)) + .map(|_| "Identical device names; use System default.".into()); + SelectionItem { + name: name.clone().unwrap_or_else(|| "System default".into()), + is_current, + disabled_reason, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PersistRealtimeDevice { + kind, + name: name.clone(), + }) + })], + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + items.push(back_item(|| AppEvent::OpenRealtimeSoundDevices)); + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(title.into()), + subtitle: Some("Applies to your next voice conversation.".into()), + items, + footer_hint: Some(standard_popup_hint_line()), + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenRealtimeSoundDevices))), ..SelectionViewParams::picker() }); } diff --git a/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs index 84657da2bd55..846b0f693415 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs @@ -1,4 +1,4 @@ -//! Voice picker rendering and selection routing without audio hardware. +//! Voice settings hierarchy, parent navigation and selection routing without audio hardware. use super::*; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; @@ -21,8 +21,18 @@ async fn voice_settings_selects_a_voice_without_starting_audio() { std::iter::from_fn(|| events.try_recv().ok()) .any(|event| matches!(event, AppEvent::OpenRealtimeSettings)) ); - chat.open_realtime_settings(Some(RealtimeVoice::Maple), RealtimeVoicesList::builtin()); + chat.open_realtime_settings(); insta::assert_snapshot!("voice_settings", render_bottom_popup(&chat, /*width*/ 80)); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenRealtimeVoices)) + ); + chat.open_realtime_voices(Some(RealtimeVoice::Maple), RealtimeVoicesList::builtin()); + insta::assert_snapshot!("voice_choices", render_bottom_popup(&chat, /*width*/ 80)); chat.bottom_pane .handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); chat.bottom_pane @@ -57,7 +67,7 @@ async fn saved_voice_confirmation_snapshot() { #[tokio::test] async fn voice_settings_uses_server_catalog() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; - chat.open_realtime_settings( + chat.open_realtime_voices( Some(RealtimeVoice::Cove), RealtimeVoicesList { v1: vec![RealtimeVoice::Cove, RealtimeVoice::Maple], @@ -69,3 +79,148 @@ async fn voice_settings_uses_server_catalog() { render_bottom_popup(&chat, /*width*/ 80) ); } + +#[tokio::test] +async fn sound_devices_picker_routes_selection_without_starting_audio() { + let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; + chat.local_settings.audio.as_mut().unwrap().microphone = + Some("Zen Go Synergy Core Playback".to_string()); + chat.open_realtime_settings(); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenRealtimeSoundDevices)) + ); + chat.open_realtime_sound_devices(); + insta::assert_snapshot!( + "voice_settings_input_channel", + render_bottom_popup(&chat, /*width*/ 90) + ); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + event, + AppEvent::OpenRealtimeDevicePicker { + kind: AudioDeviceKind::Input + } + )) + ); + assert!(ops.try_recv().is_err()); + assert!(!chat.realtime_conversation_is_running()); +} + +#[tokio::test] +async fn submenus_return_to_their_parent_with_back_or_escape() { + let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; + type OpenMenu = fn(&mut ChatWidget); + let menus: [OpenMenu; 2] = [ + |chat| chat.open_realtime_voices(/*current*/ None, RealtimeVoicesList::builtin()), + ChatWidget::open_realtime_sound_devices, + ]; + for open in menus { + for key in [KeyCode::Esc, KeyCode::Enter] { + while events.try_recv().is_ok() {} + open(&mut chat); + if key == KeyCode::Enter { + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + } + chat.bottom_pane + .handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenRealtimeSettings)) + ); + } + } + assert!(ops.try_recv().is_err()); + assert!(!chat.realtime_conversation_is_running()); +} + +#[tokio::test] +async fn device_pickers_route_selection() { + let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; + for (kind, channels, microphone, snapshot) in [ + ( + AudioDeviceKind::Input, + 2, + None, + "voice_stereo_input_devices", + ), + ( + AudioDeviceKind::Input, + 4, + None, + "voice_multichannel_input_devices", + ), + ( + AudioDeviceKind::Input, + 4, + Some("Audio interface".into()), + "voice_multichannel_selected_input_devices", + ), + (AudioDeviceKind::Output, 4, None, "voice_output_devices"), + ] { + chat.local_settings.audio.as_mut().unwrap().microphone = microphone; + let device = AudioDevice { + name: "Audio interface".into(), + channels, + is_default: true, + }; + let devices = vec![ + device.clone(), + AudioDevice { + name: "Other device".into(), + channels: 2, + is_default: false, + }, + ]; + chat.open_realtime_device_picker(kind, devices.clone()); + insta::assert_snapshot!(snapshot, render_bottom_popup(&chat, /*width*/ 80)); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + let selected = + std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event { + AppEvent::PersistRealtimeDevice { kind, name } => Some((kind, name)), + _ => None, + }); + assert_eq!(selected, Some((kind, Some(device.name.clone())))); + chat.open_realtime_device_picker(kind, devices); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenRealtimeSoundDevices)) + ); + } + assert!(ops.try_recv().is_err()); + assert!(!chat.realtime_conversation_is_running()); +} + +#[tokio::test] +async fn ambiguous_device_names_cannot_select_the_wrong_device() { + let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; + let devices = [2, 4] + .map(|channels| AudioDevice { + name: "USB microphone".into(), + channels, + is_default: false, + }) + .to_vec(); + chat.open_realtime_device_picker(AudioDeviceKind::Input, devices); + insta::assert_snapshot!( + "voice_ambiguous_input_devices", + render_bottom_popup(&chat, /*width*/ 80) + ); + while events.try_recv().is_ok() {} + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::PersistRealtimeDevice { .. })) + ); +} diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index a9c2d1932d7e..5e2e21f328b5 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -31,6 +31,7 @@ impl ChatWidget { Ok(()) } + #[cfg(test)] pub(crate) fn set_permission_profile_with_active_profile( &mut self, profile: PermissionProfile, @@ -449,7 +450,16 @@ impl ChatWidget { self.apply_thread_settings_cwd(settings.cwd.clone()); self.config.model_provider_id = settings.model_provider.clone(); self.set_service_tier(settings.service_tier.clone()); - self.set_approval_policy(settings.approval_policy); + if let Err(err) = self + .config + .permissions + .approval_policy + .set(settings.approval_policy.to_core()) + { + tracing::warn!(%err, "failed to sync approval_policy from ThreadSettingsUpdated"); + self.config.permissions.approval_policy = + Constrained::allow_only(settings.approval_policy.to_core()); + } self.set_approvals_reviewer(settings.approvals_reviewer.to_core()); self.config.personality = settings.personality; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_ambiguous_input_devices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_ambiguous_input_devices.snap new file mode 100644 index 000000000000..c6ca4fa34c94 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_ambiguous_input_devices.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Input device + Applies to your next voice conversation. + + +› 1. System default (current) + USB microphone (disabled) Identical device names; use System default. + USB microphone (disabled) Identical device names; use System default. + 2. Back + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_choices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_choices.snap new file mode 100644 index 000000000000..531afad01ac5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_choices.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Select voice + Applies to your next voice conversation. + + + 1. juniper +› 2. maple (current) + 3. spruce + 4. ember + 5. vale + 6. breeze + 7. arbor + 8. sol +↓ + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_input_devices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_input_devices.snap new file mode 100644 index 000000000000..1ecf80725776 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_input_devices.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Input device + Applies to your next voice conversation. + + +› 1. System default (current) + 2. Audio interface + 3. Other device + 4. Back + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_selected_input_devices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_selected_input_devices.snap new file mode 100644 index 000000000000..919bf1677947 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_multichannel_selected_input_devices.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Input device + Applies to your next voice conversation. + + + 1. System default +› 2. Audio interface (current) + 3. Other device + 4. Back + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_output_devices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_output_devices.snap new file mode 100644 index 000000000000..4f820944cc1b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_output_devices.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Output device + Applies to your next voice conversation. + + +› 1. System default (current) + 2. Audio interface + 3. Other device + 4. Back + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap index 531afad01ac5..f42fc409e2a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap @@ -2,17 +2,10 @@ source: tui/src/chatwidget/realtime_settings_tests.rs expression: "render_bottom_popup(&chat, 80)" --- - Select voice - Applies to your next voice conversation. + Voice settings - 1. juniper -› 2. maple (current) - 3. spruce - 4. ember - 5. vale - 6. breeze - 7. arbor - 8. sol -↓ +› 1. Set sound devices + 2. Choose a voice + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_input_channel.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_input_channel.snap new file mode 100644 index 000000000000..b5c6594671a9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_input_channel.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 90)" +--- + Sound devices + Applies to your next voice conversation. + + +› 1. Input device Zen Go Synergy Core Playback + 2. Output device System default + 3. Back + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap index a78990f3dd17..938025893fd3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap @@ -8,5 +8,6 @@ expression: "render_bottom_popup(&chat, 80)" › 1. cove (current) 2. maple + 3. Back enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_stereo_input_devices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_stereo_input_devices.snap new file mode 100644 index 000000000000..1ecf80725776 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_stereo_input_devices.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/realtime_settings_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Input device + Applies to your next voice conversation. + + +› 1. System default (current) + 2. Audio interface + 3. Other device + 4. Back + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_unnamed_server_profile.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_unnamed_server_profile.snap new file mode 100644 index 000000000000..3d4667183677 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_unnamed_server_profile.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: popup +--- + Update Model Permissions + + + 1. Ask for approval Read and edit workspace files and run commands, with approval required for internet + access or edits outside the workspace +› 2. Approve for me (current) Only ask for actions detected as potentially unsafe + 3. Full Access Use with caution: Codex can edit files outside this workspace and access the internet + without approval + 4. Read Only Read workspace files, with approval required for edits or internet access + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index e03e997d8502..b19aa7b8c598 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -625,6 +625,8 @@ async fn thread_settings_updated_updates_visible_state_without_transcript() { let mut session = configured_thread_session(thread_id); session.cwd = test_path_buf("/tmp/original-workspace").abs(); chat.handle_thread_session(session); + chat.config.permissions.approval_policy = + Constrained::allow_only(AskForApproval::Never.to_core()); let previous_generation = chat.connector_scope_generation(); let old_app = serde_json::from_str(r#"{"id":"old","name":"Old","isAccessible":true}"#) .expect("valid app"); diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index b20599a43d9d..bc607196739f 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -60,7 +60,13 @@ async fn permission_discovery_uses_server_catalog_for_remote_custom_selection() })) .unwrap(), ); - chat.request_permission_profiles(); + // Conflicting local availability must not disable server-provided choices. + chat.config.config_layer_stack = requirements_stack(codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + ..Default::default() + }); + chat.thread_id = Some(ThreadId::new()); + chat.open_permissions_popup(); let request_id = chat.permission_popup_request_id.unwrap(); rx.try_recv().unwrap(); assert_chatwidget_snapshot!( @@ -79,16 +85,6 @@ async fn permission_discovery_uses_server_catalog_for_remote_custom_selection() AppEvent::SelectPermissionProfile(PermissionProfileSelection { profile_id, .. }) if profile_id == "server-only" )); - chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); - chat.open_permissions_popup(); - rx.try_recv().unwrap(); - let request_id = chat.permission_popup_request_id.unwrap(); - let mut legacy = Discovery::local(&chat.config); - legacy.explicit_profile_mode = false; - chat.on_permission_profiles_loaded(request_id, Ok(legacy)); - let actual = render_bottom_popup(&chat, /*width*/ 110); - chat.open_legacy_permissions_popup(); - assert_eq!(actual, render_bottom_popup(&chat, /*width*/ 110)); } #[tokio::test] @@ -265,7 +261,14 @@ async fn profile_permissions_selection_popup_with_disallowed_full_access_snapsho ..Default::default() }); - chat.open_permission_profiles_popup(Discovery::local(&chat.config)); + let mut discovery = Discovery::local(&chat.config); + discovery + .profiles + .iter_mut() + .find(|profile| profile.id == ":danger-full-access") + .unwrap() + .allowed = false; + chat.open_permission_profiles_popup(discovery); assert_chatwidget_snapshot!( "profile_permissions_selection_popup_with_disallowed_full_access", @@ -1234,8 +1237,13 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure }); chat.open_permissions_popup(); + chat.on_permission_profiles_loaded( + chat.permission_popup_request_id.unwrap(), + Ok(Discovery::local(&chat.config)), + ); let popup = render_bottom_popup(&chat, /*width*/ 120); + assert_chatwidget_snapshot!("permissions_unnamed_server_profile", popup); assert!( popup.contains("Approve for me (current)"), "expected Approve for me to be current after SessionConfigured sync: {popup}" @@ -1283,6 +1291,10 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w }); chat.open_permissions_popup(); + chat.on_permission_profiles_loaded( + chat.permission_popup_request_id.unwrap(), + Ok(Discovery::local(&chat.config)), + ); let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 2ff06d2296dd..3628773dd5ed 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -425,6 +425,7 @@ pub(crate) struct AgentsKeymap { pub(crate) search: Vec, pub(crate) new_task: Vec, pub(crate) new_worktree: Vec, + pub(crate) fork: Vec, pub(crate) rename: Vec, pub(crate) stop: Vec, pub(crate) archive: Vec, @@ -1353,6 +1354,7 @@ impl RuntimeKeymap { search: resolve_local!(keymap, defaults, agents, search), new_task: resolve_local!(keymap, defaults, agents, new_task), new_worktree: resolve_local!(keymap, defaults, agents, new_worktree), + fork: resolve_local!(keymap, defaults, agents, fork), rename: resolve_local!(keymap, defaults, agents, rename), stop: resolve_local!(keymap, defaults, agents, stop), archive: resolve_local!(keymap, defaults, agents, archive), @@ -1363,40 +1365,35 @@ impl RuntimeKeymap { }; // New defaults yield to explicit bindings, including existing list shortcuts. - for (configured, bindings, alias) in [ - (keymap.agents.resume.as_ref(), &mut agents.resume, "o"), - (keymap.agents.search.as_ref(), &mut agents.search, "f"), - (keymap.agents.new_task.as_ref(), &mut agents.new_task, "n"), + for (configured, bindings) in [ + (keymap.agents.resume.as_ref(), &mut agents.resume), + (keymap.agents.search.as_ref(), &mut agents.search), + (keymap.agents.new_task.as_ref(), &mut agents.new_task), ( keymap.agents.new_worktree.as_ref(), &mut agents.new_worktree, - "w", - ), - (keymap.agents.rename.as_ref(), &mut agents.rename, "r"), - (keymap.agents.stop.as_ref(), &mut agents.stop, "x"), - (keymap.agents.archive.as_ref(), &mut agents.archive, "a"), - ( - keymap.agents.delete.as_ref(), - &mut agents.delete, - "backspace", ), - (keymap.agents.hide.as_ref(), &mut agents.hide, "h"), + (keymap.agents.fork.as_ref(), &mut agents.fork), + (keymap.agents.rename.as_ref(), &mut agents.rename), + (keymap.agents.stop.as_ref(), &mut agents.stop), + (keymap.agents.archive.as_ref(), &mut agents.archive), + (keymap.agents.delete.as_ref(), &mut agents.delete), + (keymap.agents.hide.as_ref(), &mut agents.hide), ( keymap.agents.toggle_grouping.as_ref(), &mut agents.toggle_grouping, - "g", ), ] { - if configured.is_none() - && (configured_context_alias_is_used(&keymap.agents, alias) - || configured_context_alias_is_used(&keymap.list, alias) - || configured_context_alias_is_used(&keymap.global, alias) - || chords.bindings.iter().any(|chord| { - chord.action.context.overlaps(KeymapContext::Agents) - && bindings.contains(&chord.chord.prefix) - })) - { - bindings.clear(); + if configured.is_none() { + bindings.retain(|binding| { + !configured_context_binding_is_used(&keymap.agents, *binding) + && !configured_context_binding_is_used(&keymap.list, *binding) + && !configured_context_binding_is_used(&keymap.global, *binding) + && !chords.bindings.iter().any(|chord| { + chord.action.context.overlaps(KeymapContext::Agents) + && chord.chord.prefix == *binding + }) + }); } } @@ -1542,6 +1539,7 @@ impl RuntimeKeymap { keymap.agents.new_worktree.as_ref(), &mut agents.new_worktree, ), + (keymap.agents.fork.as_ref(), &mut agents.fork), (keymap.agents.rename.as_ref(), &mut agents.rename), (keymap.agents.stop.as_ref(), &mut agents.stop), (keymap.agents.archive.as_ref(), &mut agents.archive), @@ -1912,9 +1910,10 @@ impl RuntimeKeymap { }, agents: AgentsKeymap { resume: default_bindings![plain(KeyCode::Char('o'))], - search: default_bindings![plain(KeyCode::Char('f'))], + search: default_bindings![plain(KeyCode::F(3)), plain(KeyCode::Char('/'))], new_task: default_bindings![plain(KeyCode::Char('n'))], new_worktree: default_bindings![plain(KeyCode::Char('w'))], + fork: default_bindings![plain(KeyCode::Char('f'))], rename: default_bindings![plain(KeyCode::Char('r'))], stop: default_bindings![plain(KeyCode::Char('x'))], archive: default_bindings![plain(KeyCode::Char('a'))], @@ -2312,7 +2311,11 @@ impl RuntimeKeymap { "agents", context_bindings(KeymapContext::Agents), MAIN_RESERVED_BINDINGS, - [], + [( + "search", + "fixed.slash_command", + key_hint::plain(KeyCode::Char('/')), + )], )?; for (action, bindings) in context_bindings(KeymapContext::Agents) { #[cfg(unix)] diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 3f521a4c3dec..0613f0d1fa84 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -422,6 +422,7 @@ define_runtime_action_bindings! { search, new_task, new_worktree, + fork, rename, stop, archive, diff --git a/codex-rs/tui/src/keymap/conflict_tests.rs b/codex-rs/tui/src/keymap/conflict_tests.rs index 583e99e4be32..c927337523ad 100644 --- a/codex-rs/tui/src/keymap/conflict_tests.rs +++ b/codex-rs/tui/src/keymap/conflict_tests.rs @@ -45,9 +45,11 @@ fn new_agents_defaults_preserve_existing_custom_bindings() { ("delete", "backspace"), ("hide", "h"), ("new_worktree", "w"), + ("fork", "f"), ] { for (context, existing, suffix) in [ ("agents", "stop", ""), + ("agents", "search", ""), ("list", "move_down", ""), ("global", "copy", ""), ("agents", "stop", " f12"), @@ -74,6 +76,7 @@ fn new_agents_defaults_preserve_existing_custom_bindings() { "delete" => &runtime.agents.delete, "hide" => &runtime.agents.hide, "new_worktree" => &runtime.agents.new_worktree, + "fork" => &runtime.agents.fork, _ => unreachable!(), }; assert!( @@ -150,3 +153,16 @@ fn warnings_defaults_preserve_custom_keys_and_chord_prefixes() { assert!(runtime.app.open_warnings.is_empty()); } } + +#[test] +fn agents_search_keeps_unshadowed_fallbacks() { + use crate::key_hint; + use crossterm::event::KeyCode; + + for (configured, remaining) in [("/", KeyCode::F(3)), ("f3", KeyCode::Char('/'))] { + let keymap: TuiKeymap = + serde_json::from_value(json!({"list": {"move_up": configured}})).unwrap(); + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + assert_eq!(runtime.agents.search, vec![key_hint::plain(remaining)]); + } +} diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 6359ecac8aad..46ec0ac847a7 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -223,6 +223,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("agents", "Agents", "search", "Search the available agent tasks."), action("agents", "Agents", "new_task", "Open a new session in the selected checkout."), action("agents", "Agents", "new_worktree", "Open a new session in a worktree from the project default branch."), + action("agents", "Agents", "fork", "Fork the selected conversation and open the new session."), action("agents", "Agents", "rename", "Rename the selected task."), action("agents", "Agents", "stop", "Stop the selected running task."), action("agents", "Agents", "archive", "Archive the selected task and its child agents."), @@ -408,6 +409,7 @@ pub(super) fn binding_slot<'a>( ("agents", "search") => Some(&mut keymap.agents.search), ("agents", "new_task") => Some(&mut keymap.agents.new_task), ("agents", "new_worktree") => Some(&mut keymap.agents.new_worktree), + ("agents", "fork") => Some(&mut keymap.agents.fork), ("agents", "rename") => Some(&mut keymap.agents.rename), ("agents", "stop") => Some(&mut keymap.agents.stop), ("agents", "archive") => Some(&mut keymap.agents.archive), diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 4eb78a510c34..8669f967505c 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -3,6 +3,7 @@ //! The resolved core config is a temporary input at local load/reload boundaries. Server thread //! responses must never refresh these values; live preference changes belong here. The remaining //! Config-based lifecycle adapters also use this conversion until their interfaces are migrated. +//! Audio preferences exclude project layers so thread cwd cannot route local capture. //! Effective animations also respect the TUI host's launch-time accessibility preference. //! The selected transcript ownership and alternate-screen restrictions survive local reloads. @@ -20,6 +21,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Clone, Debug, PartialEq)] pub(crate) struct LocalSettings { + pub(crate) audio: Result, pub(crate) tui: Tui, pub(crate) transcript_mode: TranscriptMode, pub(crate) history: History, @@ -56,7 +58,18 @@ impl LocalSettings { } else { config.animations }; + let mut audio = toml::Value::Table(Default::default()); + for layer in config.config_layer_stack.layers_low_to_high() { + if !matches!(layer.name, codex_config::ConfigLayerSource::Project { .. }) + && let Some(value) = layer.config.get("audio") + { + codex_config::merge_toml_values(&mut audio, value); + } + } Self { + audio: audio.try_into().map_err(|error: toml::de::Error| { + format!("Invalid machine audio settings: {error}") + }), transcript_mode: TranscriptMode::resolve( config.tui_fullscreen_transcript, config.tui_alternate_screen != codex_config::types::AltScreenMode::Never, diff --git a/codex-rs/tui/src/permission_discovery.rs b/codex-rs/tui/src/permission_discovery.rs index c40ada989ce3..2579fc6e9bd1 100644 --- a/codex-rs/tui/src/permission_discovery.rs +++ b/codex-rs/tui/src/permission_discovery.rs @@ -1,5 +1,5 @@ //! Bounded discovery for permission pickers; fixed request IDs bound abandoned RPCs. -//! This never applies a profile; the app chooses local or server-owned selection. +//! Discovery and availability come from the connected server; selection uses its settings API. use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -10,14 +10,13 @@ use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ApprovalsReviewer; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::ConfigReadParams; -use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigRequirements; use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::PermissionProfileListParams; use codex_app_server_protocol::PermissionProfileListResponse; use codex_app_server_protocol::PermissionProfileSummary; use codex_app_server_protocol::RequestId; +#[cfg(test)] use codex_utils_approval_presets::builtin_approval_presets; use std::collections::HashSet; use std::time::Duration; @@ -27,10 +26,10 @@ use uuid::Uuid; pub(crate) struct PermissionDiscovery { pub(crate) profiles: Vec, pub(crate) requirements: Option, - pub(crate) explicit_profile_mode: bool, } impl PermissionDiscovery { + #[cfg(test)] pub(crate) fn local(config: &Config) -> Self { let mut profiles = builtin_approval_presets() .into_iter() @@ -50,7 +49,6 @@ impl PermissionDiscovery { Self { profiles, requirements: None, - explicit_profile_mode: true, } } @@ -96,47 +94,8 @@ pub(crate) fn fetch( ThreadParamsMode::Remote => app_server.remote_cwd_override(), }) .map(|cwd| cwd.to_string_lossy().into_owned()); - // The daemon's catalog cannot see permission definitions from this invocation. - let local_discovery = (!app_server.uses_embedded_app_server() - && mode == ThreadParamsMode::Embedded - && config.config_layer_stack.layers_low_to_high().any(|layer| { - matches!(layer.name, codex_config::ConfigLayerSource::SessionFlags) - && layer.config.get("permissions").is_some() - })) - .then(|| PermissionDiscovery::local(config)); - let active_custom_profile = config - .permissions - .active_permission_profile() - .is_some_and(|profile| !profile.id.starts_with(':')); tokio::spawn(async move { let request = async { - if let Some(discovery) = local_discovery { - return Ok(discovery); - } - if mode == ThreadParamsMode::Remote && !active_custom_profile { - let config: ConfigReadResponse = handle - .request_typed(ClientRequest::ConfigRead { - request_id: RequestId::String("tui-permission-config".to_string()), - params: ConfigReadParams { - include_layers: false, - cwd: cwd.clone(), - }, - }) - .await - .map_err(discovery_error)?; - if !config - .config - .additional - .get("default_permissions") - .is_some_and(serde_json::Value::is_string) - { - return Ok(PermissionDiscovery { - profiles: Vec::new(), - requirements: None, - explicit_profile_mode: false, - }); - } - } let requirements: ConfigRequirementsReadResponse = handle .request_typed(ClientRequest::ConfigRequirementsRead { request_id: RequestId::String("tui-permission-requirements".to_string()), @@ -174,7 +133,6 @@ pub(crate) fn fetch( return Ok(PermissionDiscovery { profiles, requirements: requirements.requirements, - explicit_profile_mode: true, }); }; if !cursors.insert(next.clone()) { diff --git a/codex-rs/tui/src/permission_discovery_tests.rs b/codex-rs/tui/src/permission_discovery_tests.rs index 27f29bf0dff7..f40cbb2390ac 100644 --- a/codex-rs/tui/src/permission_discovery_tests.rs +++ b/codex-rs/tui/src/permission_discovery_tests.rs @@ -76,14 +76,7 @@ async fn discovery_preserves_config_scope_and_bounds_server_requests() { json!({"result": {"data": [data[1]], "nextCursor": null}}), ], }); - if mode == ThreadParamsMode::Remote { - replies.insert( - /*index*/ 0, - json!({"result": {"config": { - "default_permissions": (case != "legacy").then_some(":workspace") - }, "origins": {}}}), - ); - } else if case == "timeout" { + if case == "timeout" { replies = vec![Value::Null]; } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -151,15 +144,7 @@ async fn discovery_preserves_config_scope_and_bounds_server_requests() { continue; } let discovery = result.unwrap(); - assert_eq!(discovery.explicit_profile_mode, case != "legacy"); - if case == "session-only" { - assert!( - discovery - .profiles - .iter() - .any(|profile| profile.id == "session-only" && profile.allowed) - ); - } else if matches!(case, "empty" | "legacy") { + if case == "empty" { assert!(discovery.profiles.is_empty()); } else { assert_eq!(serde_json::to_value(&discovery.profiles).unwrap(), data); @@ -172,9 +157,8 @@ async fn discovery_preserves_config_scope_and_bounds_server_requests() { session.shutdown().await.unwrap(); let requests = server.await.unwrap(); match case { - "session-only" => assert!(requests.is_empty()), - "timeout" | "legacy" => assert_eq!(requests.len(), 1), - "local" | "remote" | "remote-default" | "thread" => { + "timeout" => assert_eq!(requests.len(), 1), + "local" | "session-only" | "remote" | "remote-default" | "thread" | "legacy" => { let pages: Vec<_> = requests .iter() .filter(|request| request["method"] == "permissionProfile/list") @@ -187,9 +171,6 @@ async fn discovery_preserves_config_scope_and_bounds_server_requests() { json!({"cwd": cwd, "limit": 100, "cursor": "second"}) ] ); - if mode == ThreadParamsMode::Remote { - assert_eq!(requests[0]["params"]["cwd"], json!(cwd)); - } } _ => {} } diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap index ea2df0d0ccba..eb1c30444b4d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap @@ -3,9 +3,10 @@ source: tui/src/keymap_setup.rs expression: agents --- Resume | o | Agents resume Resume Open the session resume picker. o Default -Search | f | Agents search Search Search the available agent tasks. f Default +Search | f3, / | Agents search Search Search the available agent tasks. f3, / Default New Task | n | Agents new_task New Task Open a new session in the selected checkout. n Default New Worktree | w | Agents new_worktree New Worktree Open a new session in a worktree from the project default branch. w Default +Fork | f | Agents fork Fork Fork the selected conversation and open the new session. f Default Rename | r | Agents rename Rename Rename the selected task. r Default Stop | x | Agents stop Stop Stop the selected running task. x Default Archive | a | Agents archive Archive Archive the selected task and its child agents. a Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 07cd8e07f13c..0e761ed368eb 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 150 actions, 1 customized, 5 unbound. + 151 actions, 1 customized, 5 unbound. All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 9ed6743cbd9b..ba5ba367003f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 151 actions, 0 customized, 6 unbound. + 152 actions, 0 customized, 6 unbound. All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 9b9e267b4b46..19f0e027b3fd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,7 +2,7 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (150 selectable) +tab: All (151 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) @@ -11,7 +11,7 @@ tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) tab: Navigation (21 selectable) -tab: Agents (10 selectable) +tab: Agents (11 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index e5e27f4066e4..cc3222de83c4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 150 actions, 1 customized, 6 unbound. + 151 actions, 1 customized, 6 unbound. All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 32f1127699e4..953b11dfffba 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 150 actions, 0 customized, 5 unbound. + 151 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index a3ecd774194c..2296ebc6185b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 150 actions, 0 customized, 5 unbound. + 151 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval › diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap index 459e314dc20f..3785b7bf0f06 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 150 actions, 0 customized, 5 unbound. + 151 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval › diff --git a/codex-rs/voice-host/src/devices.rs b/codex-rs/voice-host/src/devices.rs index a26e4aa7d57c..4d5a88c0c265 100644 --- a/codex-rs/voice-host/src/devices.rs +++ b/codex-rs/voice-host/src/devices.rs @@ -23,6 +23,7 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use codex_realtime_webrtc::AudioDeviceKind; use cpal::FromSample; use cpal::Sample; use cpal::SampleFormat; @@ -41,6 +42,77 @@ use buffers::Playback; use buffers::QUEUE_CAPACITY; use playback::PlaybackPort; +fn device( + host: &cpal::Host, + kind: AudioDeviceKind, + name: Option<&str>, +) -> io::Result { + let selected = match (kind, name) { + (AudioDeviceKind::Input, None) => host.default_input_device(), + (AudioDeviceKind::Output, None) => host.default_output_device(), + (kind, Some(name)) => { + let devices = match kind { + AudioDeviceKind::Input => host.input_devices(), + AudioDeviceKind::Output => host.output_devices(), + } + .map_err(io::Error::other)?; + eligible_devices(devices, |device| device_description(device, kind)) + .find(|(_, candidate, _)| candidate == name) + .map(|(device, _, _)| device) + } + }; + selected.ok_or_else(|| io::Error::other("selected audio device unavailable")) +} + +pub(super) fn list_devices( + kind: AudioDeviceKind, +) -> io::Result> { + let host = cpal::default_host(); + let default_id = device(&host, kind, /*name*/ None) + .ok() + .and_then(|device| device.id().ok()); + let devices = match kind { + AudioDeviceKind::Input => host.input_devices(), + AudioDeviceKind::Output => host.output_devices(), + } + .map_err(io::Error::other)?; + Ok( + eligible_devices(devices, |device| device_description(device, kind)) + .map( + |(device, name, channels)| codex_realtime_webrtc::AudioDevice { + name, + channels, + is_default: default_id.is_some() && device.id().ok() == default_id, + }, + ) + .collect(), + ) +} + +fn device_description( + device: &cpal::Device, + kind: AudioDeviceKind, +) -> Option<(String, cpal::SupportedStreamConfig)> { + let config = match kind { + AudioDeviceKind::Input => device.default_input_config(), + AudioDeviceKind::Output => device.default_output_config(), + } + .ok()?; + Some((device.description().ok()?.name().to_owned(), config)) +} + +// Listing and named startup must agree on which endpoints can be selected. +fn eligible_devices( + devices: impl Iterator, + mut describe: impl FnMut(&D) -> Option<(String, cpal::SupportedStreamConfig)>, +) -> impl Iterator { + devices.take(/*n*/ 64).filter_map(move |device| { + let (name, config) = describe(&device)?; + bounded_stream_config(&config).ok()?; + (name.chars().count() <= 256).then(|| (device, name, config.channels())) + }) +} + const MAX_CAPTURE_AGE: Duration = Duration::from_secs(/*secs*/ 1); pub(super) struct Devices { @@ -76,14 +148,14 @@ impl Devices { self.worker.buffers.take_state() } - pub(super) fn open() -> io::Result { + pub(super) fn open(selection: codex_realtime_webrtc::AudioDeviceSelection) -> io::Result { let host = cpal::default_host(); - let input = host - .default_input_device() - .ok_or_else(|| io::Error::other("microphone unavailable"))?; - let output = host - .default_output_device() - .ok_or_else(|| io::Error::other("speaker unavailable"))?; + let input = device( + &host, + AudioDeviceKind::Input, + selection.microphone.as_deref(), + )?; + let output = device(&host, AudioDeviceKind::Output, selection.speaker.as_deref())?; let input_config = input .default_input_config() .map_err(|_| io::Error::other("microphone configuration unavailable"))?; diff --git a/codex-rs/voice-host/src/devices_tests.rs b/codex-rs/voice-host/src/devices_tests.rs index e9f7b40dab41..ac1e1c9e4775 100644 --- a/codex-rs/voice-host/src/devices_tests.rs +++ b/codex-rs/voice-host/src/devices_tests.rs @@ -423,3 +423,36 @@ fn stream_xruns_recover_without_clearing_device_failure() { handle_stream_error(&buffers, cpal::ErrorKind::Xrun.into()); assert!(buffers.failed.load(Ordering::Acquire)); } + +#[test] +fn named_startup_skips_same_name_devices_omitted_from_discovery() { + let name = "USB interface"; + let supported = |channels| { + cpal::SupportedStreamConfig::new( + channels, + /*sample_rate*/ 48_000, + cpal::SupportedBufferSize::Range { min: 1, max: 4_096 }, + cpal::SampleFormat::F32, + ) + }; + // A missing default configuration and a configuration outside our bounds + // must not shadow the later selectable endpoint with the same name. + let descriptions = [None, Some(supported(64)), Some(supported(2))]; + let candidates = || { + eligible_devices(0..descriptions.len(), |index| { + descriptions[*index].map(|config| (name.into(), config)) + }) + }; + assert_eq!( + candidates().collect::>(), + vec![(2, name.to_string(), 2)] + ); + assert_eq!( + candidates().find(|(_, candidate, _)| candidate == name), + Some((2, name.to_string(), 2)) + ); + assert_eq!( + candidates().find(|(_, candidate, _)| candidate == "Unavailable"), + None + ); +} diff --git a/codex-rs/voice-host/src/devices_unavailable.rs b/codex-rs/voice-host/src/devices_unavailable.rs index 03af795eb185..238b5f385e52 100644 --- a/codex-rs/voice-host/src/devices_unavailable.rs +++ b/codex-rs/voice-host/src/devices_unavailable.rs @@ -2,31 +2,39 @@ use std::io; +pub(super) fn list_devices( + _: codex_realtime_webrtc::AudioDeviceKind, +) -> io::Result> { + Err(io::Error::other( + "audio devices unavailable for this helper target", + )) +} + pub(super) struct Devices; impl Devices { pub(super) fn receive(&self, _: crate::incoming::ReceivedRtp) -> io::Result<()> { - Self::open().map(|_| ()) + Self::open(Default::default()).map(|_| ()) } pub(super) fn take_state(&self) -> io::Result { - Self::open().map(|_| codex_realtime_webrtc::AudioState::default()) + Self::open(Default::default()).map(|_| codex_realtime_webrtc::AudioState::default()) } - pub(super) fn open() -> io::Result { + pub(super) fn open(_: codex_realtime_webrtc::AudioDeviceSelection) -> io::Result { Err(io::Error::other( "audio devices unavailable for this helper target", )) } pub(super) fn set_controls(&self, _: codex_realtime_webrtc::AudioControls) -> io::Result<()> { - Self::open().map(|_| ()) + Self::open(Default::default()).map(|_| ()) } pub(super) async fn service( &mut self, _: &mut crate::audio_track::AudioTrack, ) -> io::Result { - Self::open().map(|_| 0) + Self::open(Default::default()).map(|_| 0) } } diff --git a/codex-rs/voice-host/src/main.rs b/codex-rs/voice-host/src/main.rs index 709521aa0e1e..cd40fddf562f 100644 --- a/codex-rs/voice-host/src/main.rs +++ b/codex-rs/voice-host/src/main.rs @@ -168,9 +168,14 @@ fn run( .unwrap_or_default(), } } - Ok(Message::OpenDevices {}) if devices.is_none() && runtime.is_some() && answered => { + Ok(Message::ListDevices { kind }) => Message::DeviceList { + devices: devices::list_devices(kind)?, + }, + Ok(Message::OpenDevices { selection }) + if devices.is_none() && runtime.is_some() && answered => + { phase.set(HelperExitStage::OpenDevices); - devices = Some(devices::Devices::open()?); + devices = Some(devices::Devices::open(selection)?); Message::DevicesOpened {} } Ok(Message::SetAudioControls { controls }) => { @@ -248,7 +253,8 @@ fn run( | Message::Offer { .. } | Message::TransportReady {} | Message::TransportTimedOut {} - | Message::OpenDevices {} + | Message::DeviceList { .. } + | Message::OpenDevices { .. } | Message::DevicesOpened {} | Message::AudioControlsApplied {} | Message::AudioState { .. } diff --git a/sdk/python/src/openai_codex/generated/notification_registry.py b/sdk/python/src/openai_codex/generated/notification_registry.py index b30783706dae..af2d3590abe7 100644 --- a/sdk/python/src/openai_codex/generated/notification_registry.py +++ b/sdk/python/src/openai_codex/generated/notification_registry.py @@ -61,6 +61,7 @@ from .v2_all import ThreadGoalClearedNotification from .v2_all import ThreadGoalUpdatedNotification from .v2_all import ThreadNameUpdatedNotification +from .v2_all import ThreadPredictionUpdatedNotification from .v2_all import ThreadProjectUpdatedNotification from .v2_all import ThreadQueueChangedNotification from .v2_all import ThreadRealtimeClosedNotification @@ -144,6 +145,7 @@ | ThreadGoalClearedNotification | ThreadGoalUpdatedNotification | ThreadNameUpdatedNotification + | ThreadPredictionUpdatedNotification | ThreadProjectUpdatedNotification | ThreadQueueChangedNotification | ThreadRealtimeClosedNotification @@ -230,6 +232,7 @@ "thread/goal/cleared": ThreadGoalClearedNotification, "thread/goal/updated": ThreadGoalUpdatedNotification, "thread/name/updated": ThreadNameUpdatedNotification, + "thread/prediction/updated": ThreadPredictionUpdatedNotification, "thread/project/updated": ThreadProjectUpdatedNotification, "thread/queue/changed": ThreadQueueChangedNotification, "thread/realtime/closed": ThreadRealtimeClosedNotification, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index f9ee2be07fd6..6a2edc47255b 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -5613,6 +5613,39 @@ class ThreadNameUpdatedNotification(BaseModel): thread_name: Annotated[str | None, Field(alias="threadName")] = None +class CompletedThreadPredictionResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + text: str | None = None + type: Annotated[Literal["completed"], Field(title="CompletedThreadPredictionResultType")] + + +class FailedThreadPredictionResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[Literal["failed"], Field(title="FailedThreadPredictionResultType")] + + +class ThreadPredictionResult( + RootModel[CompletedThreadPredictionResult | FailedThreadPredictionResult] +): + model_config = ConfigDict( + populate_by_name=True, + ) + root: CompletedThreadPredictionResult | FailedThreadPredictionResult + + +class ThreadPredictionUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + result: ThreadPredictionResult + source_turn_id: Annotated[str, Field(alias="sourceTurnId")] + thread_id: Annotated[str, Field(alias="threadId")] + + class ThreadProjectUpdatedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -9178,6 +9211,24 @@ class ThreadAttachmentUpdatedServerNotification(BaseModel): params: ThreadAttachmentUpdatedNotification +class ThreadPredictionUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + emitted_at_ms: Annotated[ + int | None, + Field( + alias="emittedAtMs", + description="Unix timestamp (in milliseconds) when app-server emitted this notification.", + ), + ] = None + method: Annotated[ + Literal["thread/prediction/updated"], + Field(title="Thread/prediction/updatedNotificationMethod"), + ] + params: ThreadPredictionUpdatedNotification + + class ThreadGoalClearedServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -12859,6 +12910,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadAttachmentUpdatedServerNotification | ThreadGoalUpdatedServerNotification + | ThreadPredictionUpdatedServerNotification | ThreadGoalClearedServerNotification | ThreadQueueChangedServerNotification | ProjectChangedServerNotification @@ -12948,6 +13000,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadAttachmentUpdatedServerNotification | ThreadGoalUpdatedServerNotification + | ThreadPredictionUpdatedServerNotification | ThreadGoalClearedServerNotification | ThreadQueueChangedServerNotification | ProjectChangedServerNotification