Merge 60ba7ddcb7 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-06-22 15:40:11 -07:00
committed by GitHub
110 changed files with 3907 additions and 312 deletions
+1 -1
View File
@@ -309,7 +309,7 @@ crate.annotation(
# Build scripts compile in Bazel's exec configuration, so target-specific
# optional build deps are otherwise dropped for the musl release platforms.
build_script_deps = [
"@crates//:openssl-src-300.5.5+3.5.5",
"@crates//:openssl-src-300.6.1+3.6.3",
],
build_script_env = {
"OPENSSL_DIR": "$(execpath @openssl//:gen_dir)",
+4 -4
View File
@@ -986,8 +986,8 @@
"git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}",
"git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad_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=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib-rs\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}",
"git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}",
"gix-actor_0.40.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\",\"unicode\"],\"name\":\"bstr\",\"req\":\"^1.12.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"gix-date\",\"req\":\"^0.15.0\"},{\"name\":\"gix-error\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.114\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.14\"}],\"features\":{\"serde\":[\"dep:serde\",\"bstr/serde\",\"gix-date/serde\"]}}",
@@ -1197,7 +1197,6 @@
"libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}",
"libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}",
"libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}",
"libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}",
"libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}",
"link-cplusplus_1.0.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"libc++\":[],\"libcxx\":[\"libc++\"],\"libstdc++\":[],\"libstdcxx\":[\"libstdc++\"],\"nothing\":[]}}",
"link-section_0.17.2": "{\"dependencies\":[{\"features\":[\"link_section\"],\"name\":\"linktime-proc-macro\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"macrotest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2\"}],\"features\":{\"default\":[\"proc_macro\",\"std\"],\"proc_macro\":[\"dep:linktime-proc-macro\"],\"std\":[]}}",
@@ -1294,7 +1293,7 @@
"openssl-macros_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}",
"openssl-probe_0.1.6": "{\"dependencies\":[],\"features\":{}}",
"openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}",
"openssl-src_300.5.5+3.5.5": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{\"camellia\":[],\"default\":[],\"force-engine\":[],\"idea\":[],\"ktls\":[],\"legacy\":[],\"no-dso\":[],\"seed\":[],\"ssl3\":[],\"weak-crypto\":[]}}",
"openssl-src_300.6.1+3.6.3": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{\"camellia\":[],\"default\":[],\"force-engine\":[],\"idea\":[],\"ktls\":[],\"legacy\":[],\"no-dso\":[],\"seed\":[],\"ssl3\":[],\"weak-crypto\":[]}}",
"openssl-sys_0.9.111": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}",
"openssl-sys_0.9.112": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.38\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}",
"openssl_0.10.75": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.2.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"ffi\",\"package\":\"openssl-sys\",\"req\":\"^0.9.111\"},{\"name\":\"foreign-types\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.5.2\"},{\"name\":\"openssl-macros\",\"req\":\"^0.1.1\"}],\"features\":{\"aws-lc\":[\"ffi/aws-lc\"],\"aws-lc-fips\":[\"ffi/aws-lc-fips\"],\"bindgen\":[\"ffi/bindgen\"],\"default\":[],\"unstable_boringssl\":[\"ffi/unstable_boringssl\"],\"v101\":[],\"v102\":[],\"v110\":[],\"v111\":[],\"vendored\":[\"ffi/vendored\"]}}",
@@ -1876,6 +1875,7 @@
"zerovec_0.11.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.43.2\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"schemars\":[\"dep:schemars\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}",
"zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}",
"zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}",
"zlib-rs_0.5.5": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}",
"zlib-rs_0.6.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-api\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}",
"zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}",
"zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}",
+20 -19
View File
@@ -2136,6 +2136,7 @@ dependencies = [
"codex-otel",
"codex-protocol",
"codex-utils-cli",
"pretty_assertions",
"serde",
"serde_json",
"tokio",
@@ -2403,12 +2404,14 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"windows-sys 0.52.0",
"zstd 0.13.3",
]
@@ -3529,6 +3532,8 @@ dependencies = [
"rama-tls-rustls",
"rama-unix",
"rustls-native-certs",
"schannel",
"security-framework 3.5.1",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -4016,6 +4021,7 @@ name = "codex-tui"
version = "0.0.0"
dependencies = [
"anyhow",
"app_test_support",
"arboard",
"assert_matches",
"base64 0.22.1",
@@ -6024,8 +6030,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
dependencies = [
"crc32fast",
"libz-sys",
"miniz_oxide",
"zlib-rs 0.5.5",
]
[[package]]
@@ -6713,7 +6719,7 @@ dependencies = [
"prodash",
"thiserror 2.0.18",
"walkdir",
"zlib-rs",
"zlib-rs 0.6.3",
]
[[package]]
@@ -8766,17 +8772,6 @@ dependencies = [
"webrtc-sys",
]
[[package]]
name = "libz-sys"
version = "1.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "link-cplusplus"
version = "1.0.12"
@@ -9453,7 +9448,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.22.1",
"base64 0.21.7",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -9740,9 +9735,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.5.5+3.5.5"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
@@ -10539,7 +10534,7 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
"heck 0.5.0",
"heck 0.4.1",
"itertools 0.14.0",
"log",
"multimap",
@@ -13604,7 +13599,7 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad"
source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186"
dependencies = [
"futures-util",
"log",
@@ -14042,7 +14037,7 @@ dependencies = [
[[package]]
name = "tungstenite"
version = "0.27.0"
source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f"
source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f"
dependencies = [
"bytes",
"data-encoding",
@@ -15803,6 +15798,12 @@ dependencies = [
"zstd 0.13.3",
]
[[package]]
name = "zlib-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
[[package]]
name = "zlib-rs"
version = "0.6.3"
+3 -3
View File
@@ -546,11 +546,11 @@ opt-level = 0
# ratatui = { path = "../../ratatui" }
crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" }
ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" }
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "e5e64b86722c5453c4038131c3193faa49a648ad" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" }
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
# Uncomment to debug local changes.
# rmcp = { path = "../../rust-sdk/crates/rmcp" }
[patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"]
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
+24 -4
View File
@@ -119,7 +119,7 @@ pub struct AgentIdentityJwtClaims {
pub agent_private_key: String,
pub account_id: String,
pub chatgpt_user_id: String,
pub email: String,
pub email: Option<String>,
pub plan_type: AuthPlanType,
pub chatgpt_account_is_fedramp: bool,
}
@@ -667,13 +667,33 @@ mod tests {
agent_private_key: "private-key".to_string(),
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: AuthPlanType::Known(KnownPlan::Pro),
chatgpt_account_is_fedramp: false,
}
);
}
#[test]
fn decode_agent_identity_jwt_accepts_missing_email() {
let jwt = jwt_with_payload(serde_json::json!({
"iss": AGENT_IDENTITY_JWT_ISSUER,
"aud": AGENT_IDENTITY_JWT_AUDIENCE,
"iat": 1_700_000_000usize,
"exp": 4_000_000_000usize,
"agent_runtime_id": "agent-runtime-id",
"agent_private_key": "private-key",
"account_id": "account-id",
"chatgpt_user_id": "user-id",
"plan_type": "pro",
"chatgpt_account_is_fedramp": false,
}));
let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode");
assert_eq!(claims.email, None);
}
#[test]
fn decode_agent_identity_jwt_maps_raw_plan_aliases() {
let jwt = jwt_with_payload(serde_json::json!({
@@ -707,7 +727,7 @@ mod tests {
agent_private_key: "private-key".to_string(),
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: AuthPlanType::Known(KnownPlan::Pro),
chatgpt_account_is_fedramp: false,
};
@@ -739,7 +759,7 @@ mod tests {
agent_private_key: "private-key".to_string(),
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: AuthPlanType::Known(KnownPlan::Pro),
chatgpt_account_is_fedramp: false,
};
@@ -5900,7 +5900,10 @@
{
"properties": {
"email": {
"type": "string"
"type": [
"string",
"null"
]
},
"planType": {
"$ref": "#/definitions/v2/PlanType"
@@ -13042,6 +13045,10 @@
},
"PermissionProfileSummary": {
"properties": {
"allowed": {
"description": "Whether the effective requirements allow selecting this profile.",
"type": "boolean"
},
"description": {
"description": "Optional user-facing description for display in clients.",
"type": [
@@ -13055,6 +13062,7 @@
}
},
"required": [
"allowed",
"id"
],
"type": "object"
@@ -26,7 +26,10 @@
{
"properties": {
"email": {
"type": "string"
"type": [
"string",
"null"
]
},
"planType": {
"$ref": "#/definitions/PlanType"
@@ -9446,6 +9449,10 @@
},
"PermissionProfileSummary": {
"properties": {
"allowed": {
"description": "Whether the effective requirements allow selecting this profile.",
"type": "boolean"
},
"description": {
"description": "Optional user-facing description for display in clients.",
"type": [
@@ -9459,6 +9466,7 @@
}
},
"required": [
"allowed",
"id"
],
"type": "object"
@@ -22,7 +22,10 @@
{
"properties": {
"email": {
"type": "string"
"type": [
"string",
"null"
]
},
"planType": {
"$ref": "#/definitions/PlanType"
@@ -3,6 +3,10 @@
"definitions": {
"PermissionProfileSummary": {
"properties": {
"allowed": {
"description": "Whether the effective requirements allow selecting this profile.",
"type": "boolean"
},
"description": {
"description": "Optional user-facing description for display in clients.",
"type": [
@@ -16,6 +20,7 @@
}
},
"required": [
"allowed",
"id"
],
"type": "object"
@@ -4,4 +4,4 @@
import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource";
import type { PlanType } from "../PlanType";
export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, };
export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, };
@@ -10,4 +10,8 @@ id: string,
/**
* Optional user-facing description for display in clients.
*/
description: string | null, };
description: string | null,
/**
* Whether the effective requirements allow selecting this profile.
*/
allowed: boolean, };
@@ -2854,7 +2854,7 @@ mod tests {
);
let chatgpt = v2::Account::Chatgpt {
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: PlanType::Plus,
};
assert_eq!(
@@ -2866,6 +2866,19 @@ mod tests {
serde_json::to_value(&chatgpt)?,
);
let chatgpt_without_email = v2::Account::Chatgpt {
email: None,
plan_type: PlanType::Pro,
};
assert_eq!(
json!({
"type": "chatgpt",
"email": null,
"planType": "pro",
}),
serde_json::to_value(&chatgpt_without_email)?,
);
let codex_managed_bedrock = v2::Account::AmazonBedrock {
credential_source: AmazonBedrockCredentialSource::CodexManaged,
};
@@ -25,7 +25,11 @@ pub enum Account {
#[serde(rename = "chatgpt", rename_all = "camelCase")]
#[ts(rename = "chatgpt", rename_all = "camelCase")]
Chatgpt { email: String, plan_type: PlanType },
Chatgpt {
#[schemars(required, schema_with = "nullable_string_schema")]
email: Option<String>,
plan_type: PlanType,
},
#[serde(rename = "amazonBedrock", rename_all = "camelCase")]
#[ts(rename = "amazonBedrock", rename_all = "camelCase")]
@@ -35,6 +39,12 @@ pub enum Account {
},
}
fn nullable_string_schema(
generator: &mut schemars::r#gen::SchemaGenerator,
) -> schemars::schema::Schema {
generator.subschema_for::<Option<String>>()
}
fn default_bedrock_credential_source() -> AmazonBedrockCredentialSource {
AmazonBedrockCredentialSource::AwsManaged
}
@@ -378,6 +378,8 @@ pub struct PermissionProfileSummary {
pub id: String,
/// Optional user-facing description for display in clients.
pub description: Option<String>,
/// Whether the effective requirements allow selecting this profile.
pub allowed: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -26,3 +26,6 @@ uuid = { workspace = true, features = ["v4"] }
[lib]
doctest = false
[dev-dependencies]
pretty_assertions = { workspace = true }
@@ -18,6 +18,10 @@ cargo run -p codex-app-server-test-client -- \
cargo run -p codex-app-server-test-client -- model-list
```
`send-message` and `send-message-v2` handle `request_user_input` server requests interactively.
When Codex asks a question, choose a numbered option (or `o` for a free-form answer when offered)
and the client will send the response and continue streaming the same turn.
## Testing Plugin Analytics
The `plugin-analytics-smoke` command exercises `plugin/installed`, plugin
@@ -91,6 +91,7 @@ mod loopback_responses_server;
mod plugin_analytics_capture;
mod plugin_analytics_mutation_smoke;
mod plugin_analytics_smoke;
mod request_user_input;
const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
// v2 item deltas.
@@ -2040,6 +2041,10 @@ impl CodexClient {
ServerRequest::FileChangeRequestApproval { request_id, params } => {
self.approve_file_change_request(request_id, params)?;
}
ServerRequest::ToolRequestUserInput { request_id, params } => {
let response = request_user_input::prompt_for_answers(&params)?;
self.send_server_request_response(request_id, &response)?;
}
other => {
bail!("received unsupported server request: {other:?}");
}
@@ -0,0 +1,148 @@
use std::collections::HashMap;
use std::io;
use std::io::BufRead;
use std::io::IsTerminal;
use std::io::Write;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_app_server_protocol::ToolRequestUserInputAnswer;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputResponse;
pub(super) fn prompt_for_answers(
params: &ToolRequestUserInputParams,
) -> Result<ToolRequestUserInputResponse> {
let stdin = io::stdin();
if !stdin.is_terminal() {
bail!("request_user_input requires an interactive stdin terminal");
}
let stdout = io::stdout();
prompt_for_answers_with(&mut stdin.lock(), &mut stdout.lock(), params)
}
fn prompt_for_answers_with(
input: &mut impl BufRead,
output: &mut impl Write,
params: &ToolRequestUserInputParams,
) -> Result<ToolRequestUserInputResponse> {
writeln!(
output,
"\n[request_user_input for thread {}, turn {}]",
params.thread_id, params.turn_id
)?;
if let Some(auto_resolution_ms) = params.auto_resolution_ms {
writeln!(
output,
"The app-server may auto-resolve this request after {auto_resolution_ms} ms."
)?;
}
let mut answers = HashMap::new();
for question in &params.questions {
writeln!(output, "\n{}: {}", question.header, question.question)?;
let options = question
.options
.as_deref()
.filter(|options| !options.is_empty());
let answer_values = if let Some(options) = options {
for (index, option) in options.iter().enumerate() {
writeln!(
output,
" {}. {} - {}",
index + 1,
option.label,
option.description
)?;
}
if question.is_other {
writeln!(output, " o. Other (free-form)")?;
}
loop {
if question.is_other {
write!(output, "Choose 1-{} or o: ", options.len())?;
} else {
write!(output, "Choose 1-{}: ", options.len())?;
}
output.flush()?;
let mut line = String::new();
if input
.read_line(&mut line)
.context("failed to read request_user_input selection")?
== 0
{
bail!("stdin closed while waiting for request_user_input selection");
}
let selection = line.trim();
if let Ok(index) = selection.parse::<usize>()
&& let Some(option) = index.checked_sub(1).and_then(|index| options.get(index))
{
break vec![option.label.clone()];
}
if let Some(option) = options
.iter()
.find(|option| option.label.eq_ignore_ascii_case(selection))
{
break vec![option.label.clone()];
}
if question.is_other && selection.eq_ignore_ascii_case("o") {
write!(output, "Other: ")?;
output.flush()?;
line.clear();
if input
.read_line(&mut line)
.context("failed to read request_user_input free-form answer")?
== 0
{
bail!("stdin closed while waiting for request_user_input free-form answer");
}
let answer = line.trim();
if !answer.is_empty() {
break vec![format!("user_note: {answer}")];
}
}
writeln!(output, "Invalid selection; try again.")?;
}
} else {
loop {
write!(output, "Answer: ")?;
output.flush()?;
let mut line = String::new();
if input
.read_line(&mut line)
.context("failed to read request_user_input answer")?
== 0
{
bail!("stdin closed while waiting for request_user_input answer");
}
let answer = line.trim();
if !answer.is_empty() {
break vec![format!("user_note: {answer}")];
}
writeln!(output, "Answer cannot be empty; try again.")?;
}
};
answers.insert(
question.id.clone(),
ToolRequestUserInputAnswer {
answers: answer_values,
},
);
}
Ok(ToolRequestUserInputResponse { answers })
}
#[cfg(test)]
#[path = "request_user_input_tests.rs"]
mod tests;
@@ -0,0 +1,125 @@
use std::collections::HashMap;
use std::io::Cursor;
use codex_app_server_protocol::ToolRequestUserInputAnswer;
use codex_app_server_protocol::ToolRequestUserInputOption;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use pretty_assertions::assert_eq;
use super::prompt_for_answers_with;
#[test]
fn collects_option_and_free_form_answers() {
let params = ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
questions: vec![
ToolRequestUserInputQuestion {
id: "target".to_string(),
header: "Target".to_string(),
question: "Which target?".to_string(),
is_other: true,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Core".to_string(),
description: "Inspect core".to_string(),
},
ToolRequestUserInputOption {
label: "TUI".to_string(),
description: "Inspect TUI".to_string(),
},
]),
},
ToolRequestUserInputQuestion {
id: "details".to_string(),
header: "Details".to_string(),
question: "Anything else?".to_string(),
is_other: true,
is_secret: false,
options: None,
},
],
auto_resolution_ms: Some(60_000),
};
let mut input = Cursor::new(b"2\ninclude snapshots\n");
let mut output = Vec::new();
let response = prompt_for_answers_with(&mut input, &mut output, &params).unwrap();
assert_eq!(
response,
ToolRequestUserInputResponse {
answers: HashMap::from([
(
"target".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["TUI".to_string()],
},
),
(
"details".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["user_note: include snapshots".to_string()],
},
),
]),
}
);
assert_eq!(
String::from_utf8(output).unwrap(),
concat!(
"\n[request_user_input for thread thread-1, turn turn-1]\n",
"The app-server may auto-resolve this request after 60000 ms.\n",
"\nTarget: Which target?\n",
" 1. Core - Inspect core\n",
" 2. TUI - Inspect TUI\n",
" o. Other (free-form)\n",
"Choose 1-2 or o: ",
"\nDetails: Anything else?\n",
"Answer: ",
)
);
}
#[test]
fn retries_invalid_selection_and_collects_other_answer() {
let params = ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
questions: vec![ToolRequestUserInputQuestion {
id: "target".to_string(),
header: "Target".to_string(),
question: "Which target?".to_string(),
is_other: true,
is_secret: false,
options: Some(vec![ToolRequestUserInputOption {
label: "Core".to_string(),
description: "Inspect core".to_string(),
}]),
}],
auto_resolution_ms: None,
};
let mut input = Cursor::new(b"9\no\nSDK wrapper\n");
let mut output = Vec::new();
let response = prompt_for_answers_with(&mut input, &mut output, &params).unwrap();
assert_eq!(
response,
ToolRequestUserInputResponse {
answers: HashMap::from([(
"target".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["user_note: SDK wrapper".to_string()],
},
)]),
}
);
let output = String::from_utf8(output).unwrap();
assert!(output.contains("Invalid selection; try again."));
}
@@ -1041,6 +1041,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let (transport_event_tx, _transport_event_rx) =
@@ -1870,6 +1871,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
@@ -1966,6 +1968,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let remote_control_target =
@@ -185,6 +185,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut fresh_auth = remote_control_auth_dot_json(Some("account_id"));
@@ -270,6 +271,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut fresh_auth = remote_control_auth_dot_json(Some("account_id"));
@@ -539,6 +539,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut fresh_auth = remote_control_auth_dot_json(Some("account_id"));
@@ -808,6 +809,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let remote_handle =
@@ -2214,6 +2214,7 @@ mod tests {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
@@ -2312,6 +2313,7 @@ mod tests {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
@@ -2435,6 +2437,7 @@ mod tests {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
+3 -1
View File
@@ -202,7 +202,7 @@ Example with notification opt-out:
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored.
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
@@ -1941,6 +1941,7 @@ Response examples:
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": null, "planType": "enterprise" }, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "codexManaged" }, "requiresOpenaiAuth": false } }
{ "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "awsManaged" }, "requiresOpenaiAuth": false } }
```
@@ -1948,6 +1949,7 @@ Response examples:
Field notes:
- `refreshToken` (bool): set `true` to force a token refresh.
- `email` is `null` when the ChatGPT account does not have an email address.
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
- Amazon Bedrock reports `credentialSource: "codexManaged"` when it uses a Bedrock API key managed by Codex. Otherwise it reports `credentialSource: "awsManaged"` for the external AWS credential path. This identifies the selected credential source; it does not validate that the AWS credential chain can resolve credentials.
@@ -396,9 +396,6 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
#[cfg(test)]
use codex_protocol::items::TurnItem;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
#[cfg(test)]
@@ -359,6 +359,7 @@ impl AccountRequestProcessor {
config.forced_chatgpt_workspace_id.clone(),
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
};
#[cfg(debug_assertions)]
@@ -1,5 +1,5 @@
use super::*;
use codex_config::config_toml::ConfigToml;
use codex_core::config::permission_profile_catalog;
use futures::StreamExt;
#[derive(Clone)]
@@ -434,35 +434,15 @@ impl CatalogRequestProcessor {
.await
.map_err(|err| internal_error(format!("failed to reload config: {err}")))?,
};
let effective_config: ConfigToml = config_layer_stack
.effective_config()
.try_into()
.map_err(|err| internal_error(format!("failed to read effective config: {err}")))?;
let mut profiles = vec![
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
description: None,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
description: None,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
description: None,
},
];
let mut configured_profiles = effective_config
.permissions
let profiles = permission_profile_catalog(&config_layer_stack)
.map_err(|err| internal_error(format!("failed to resolve permission profiles: {err}")))?
.into_iter()
.flat_map(|permissions| permissions.entries)
.map(|(id, profile)| PermissionProfileSummary {
id,
.map(|profile| PermissionProfileSummary {
id: profile.id,
description: profile.description,
allowed: profile.allowed,
})
.collect::<Vec<_>>();
configured_profiles.sort_by(|left, right| left.id.cmp(&right.id));
profiles.extend(configured_profiles);
let total = profiles.len();
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = effective_limit.min(total);
@@ -745,11 +745,10 @@ impl PluginRequestProcessor {
);
}
let featured_plugin_ids = if !plugins_input.remote_plugin_enabled
&& data
.iter()
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
{
let featured_plugin_ids = if data.iter().any(|marketplace| {
marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME
|| marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME
}) {
match plugins_manager
.featured_plugin_ids_for_config(&plugins_input, auth.as_ref())
.await
+34 -2
View File
@@ -5,7 +5,10 @@ use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use chrono::Duration;
use chrono::Utc;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::JSONRPCError;
@@ -14,6 +17,7 @@ use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::account::PlanType as AccountPlanType;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
@@ -162,7 +166,7 @@ async fn get_auth_status_with_api_key() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> {
async fn personal_access_token_without_email_supports_auth_status_and_account_read() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
@@ -171,7 +175,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()>
.and(path("/v1/user-auth-credential/whoami"))
.and(header("Authorization", "Bearer at-test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"email": "user@example.com",
"email": null,
"chatgpt_user_id": "user-123",
"chatgpt_account_id": "account-123",
"chatgpt_plan_type": "pro",
@@ -215,6 +219,34 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()>
}
);
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
response
.result
.get("account")
.and_then(|account| account.get("email")),
Some(&serde_json::Value::Null),
);
assert_eq!(
to_response::<GetAccountResponse>(response)?,
GetAccountResponse {
account: Some(Account::Chatgpt {
email: None,
plan_type: AccountPlanType::Pro,
}),
requires_openai_auth: true,
}
);
server.verify().await;
Ok(())
}
+49 -4
View File
@@ -320,7 +320,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
account,
GetAccountResponse {
account: Some(Account::Chatgpt {
email: "embedded@example.com".to_string(),
email: Some("embedded@example.com".to_string()),
plan_type: AccountPlanType::Pro,
}),
requires_openai_auth: true,
@@ -388,7 +388,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
account,
GetAccountResponse {
account: Some(Account::Chatgpt {
email: "embedded@example.com".to_string(),
email: Some("embedded@example.com".to_string()),
plan_type: AccountPlanType::Pro,
}),
requires_openai_auth: true,
@@ -1817,7 +1817,7 @@ async fn get_account_with_chatgpt() -> Result<()> {
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: AccountPlanType::Pro,
}),
requires_openai_auth: true,
@@ -1826,6 +1826,51 @@ async fn get_account_with_chatgpt() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_without_email() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(response)?;
assert_eq!(
received,
GetAccountResponse {
account: Some(Account::Chatgpt {
email: None,
plan_type: AccountPlanType::Pro,
}),
requires_openai_auth: true,
}
);
Ok(())
}
#[tokio::test]
async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1944,7 +1989,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: AccountPlanType::Unknown,
}),
requires_openai_auth: true,
@@ -60,22 +60,27 @@ description = "Inspect without writes."
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: "audit".to_string(),
description: Some("Inspect without writes.".to_string()),
allowed: true,
},
PermissionProfileSummary {
id: "dev".to_string(),
description: Some("Day-to-day coding work.".to_string()),
allowed: true,
},
],
next_cursor: None,
@@ -126,14 +131,17 @@ description = "Project-scoped profile."
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
description: None,
allowed: true,
},
],
next_cursor: Some("3".to_string()),
@@ -155,6 +163,7 @@ description = "Project-scoped profile."
data: vec![PermissionProfileSummary {
id: "project".to_string(),
description: Some("Project-scoped profile.".to_string()),
allowed: true,
}],
next_cursor: None,
}
@@ -200,18 +209,22 @@ description = "Project-scoped profile."
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
description: None,
allowed: true,
},
PermissionProfileSummary {
id: "project".to_string(),
description: Some("Project-scoped profile.".to_string()),
allowed: true,
},
],
next_cursor: None,
@@ -1761,6 +1761,14 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated-remote"]"#),
)
.mount(&server)
.await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -1854,7 +1862,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
cached_plugin_ids,
vec!["plugins~Plugin_00000000000000000000000000000000".to_string()]
);
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
assert_eq!(
response.featured_plugin_ids,
vec!["linear@openai-curated-remote".to_string()]
);
assert!(
!server
.received_requests()
@@ -204,12 +204,11 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> {
"rate limit reset consume timed out"
);
let account_error = read_error_response(&mut mcp, account_id).await?;
assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
account_error.error.message,
"email and plan type are required for chatgpt authentication"
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(account_id)),
)
.await??;
Ok(())
}
+20 -1
View File
@@ -11,6 +11,7 @@ use codex_app_server_protocol::AuthMode;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::Config;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthRouteConfig;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions;
@@ -119,11 +120,13 @@ async fn clear_existing_auth_before_login(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
auth_keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<&AuthRouteConfig>,
) {
if let Err(err) = logout_with_revoke(
codex_home,
auth_credentials_store_mode,
auth_keyring_backend_kind,
auth_route_config,
)
.await
{
@@ -136,11 +139,13 @@ pub async fn login_with_chatgpt(
forced_chatgpt_workspace_id: Option<Vec<String>>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
auth_keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> std::io::Result<()> {
clear_existing_auth_before_login(
&codex_home,
cli_auth_credentials_store_mode,
auth_keyring_backend_kind,
auth_route_config.as_ref(),
)
.await;
@@ -150,6 +155,7 @@ pub async fn login_with_chatgpt(
forced_chatgpt_workspace_id,
cli_auth_credentials_store_mode,
auth_keyring_backend_kind,
auth_route_config,
);
let server = run_login_server(opts)?;
@@ -169,12 +175,12 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
}
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
match login_with_chatgpt(
config.codex_home.to_path_buf(),
forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
.await
{
@@ -232,6 +238,7 @@ pub async fn run_login_with_access_token(
std::process::exit(1);
}
let auth_route_config = config.auth_route_config();
match login_with_access_token(
&config.codex_home,
&access_token,
@@ -239,6 +246,7 @@ pub async fn run_login_with_access_token(
config.forced_chatgpt_workspace_id.as_deref(),
Some(&config.chatgpt_base_url),
config.auth_keyring_backend_kind(),
auth_route_config.as_ref(),
)
.await
{
@@ -307,10 +315,12 @@ pub async fn run_login_with_device_code(
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
let auth_route_config = config.auth_route_config();
clear_existing_auth_before_login(
&config.codex_home,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
auth_route_config.as_ref(),
)
.await;
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
@@ -320,6 +330,7 @@ pub async fn run_login_with_device_code(
forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
auth_route_config,
);
if let Some(iss) = issuer_base_url {
opts.issuer = iss;
@@ -352,10 +363,12 @@ pub async fn run_login_with_device_code_fallback_to_browser(
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
let auth_route_config = config.auth_route_config();
clear_existing_auth_before_login(
&config.codex_home,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
auth_route_config.as_ref(),
)
.await;
@@ -366,6 +379,7 @@ pub async fn run_login_with_device_code_fallback_to_browser(
forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
auth_route_config,
);
if let Some(iss) = issuer_base_url {
opts.issuer = iss;
@@ -409,12 +423,14 @@ pub async fn run_login_with_device_code_fallback_to_browser(
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
let auth_route_config = config.auth_route_config();
match CodexAuth::from_auth_storage(
&config.codex_home,
config.cli_auth_credentials_store_mode,
Some(&config.chatgpt_base_url),
config.auth_keyring_backend_kind(),
auth_route_config.as_ref(),
)
.await
{
@@ -459,11 +475,13 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
let auth_route_config = config.auth_route_config();
match logout_with_revoke(
&config.codex_home,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
auth_route_config.as_ref(),
)
.await
{
@@ -536,6 +554,7 @@ mod tests {
codex_home.path(),
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
+7 -3
View File
@@ -1738,9 +1738,13 @@ async fn load_exec_server_remote_auth_provider(
let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| {
anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set")
})?;
let auth =
CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url))
.await?;
let auth_route_config = config.auth_route_config();
let auth = CodexAuth::from_agent_identity_jwt(
&agent_identity_jwt,
Some(&config.chatgpt_base_url),
auth_route_config.as_ref(),
)
.await?;
return Ok(codex_model_provider::auth_provider_from_auth(&auth));
}
+2
View File
@@ -566,11 +566,13 @@ pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option<AuthMode> {
return Some(CodexAuth::from_api_key(&api_key).api_auth_mode());
}
let auth_route_config = config.auth_route_config();
CodexAuth::from_auth_storage(
&config.codex_home,
config.cli_auth_credentials_store_mode,
Some(&config.chatgpt_base_url),
config.auth_keyring_backend_kind(),
auth_route_config.as_ref(),
)
.await
.ok()
@@ -7,6 +7,7 @@ use codex_config::CloudConfigBundleLoader;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
use codex_login::AuthRouteConfig;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
@@ -58,6 +59,7 @@ pub async fn cloud_config_bundle_loader_for_storage(
credentials_store_mode: AuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
chatgpt_base_url: String,
auth_route_config: Option<AuthRouteConfig>,
) -> CloudConfigBundleLoader {
let auth_manager = AuthManager::shared(
codex_home.clone(),
@@ -66,6 +68,7 @@ pub async fn cloud_config_bundle_loader_for_storage(
/*forced_chatgpt_workspace_id*/ None,
Some(chatgpt_base_url.clone()),
keyring_backend_kind,
auth_route_config,
)
.await;
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home)
+8 -1
View File
@@ -53,6 +53,7 @@ async fn auth_manager_with_api_key() -> Arc<AuthManager> {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
)
@@ -83,6 +84,7 @@ async fn auth_manager_with_plan_and_identity(
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
)
@@ -102,12 +104,13 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc<AuthManager> {
agent_private_key: key_material.private_key_pkcs8_base64,
account_id: "account-12345".to_string(),
chatgpt_user_id: "user-12345".to_string(),
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: PlanType::Business,
chatgpt_account_is_fedramp: false,
task_id: Some("task-123".to_string()),
},
"https://auth.openai.com/api/accounts",
/*auth_route_config*/ None,
)
.await
.expect("agent identity record should be complete"),
@@ -686,6 +689,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
);
@@ -742,6 +746,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity()
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
);
@@ -806,6 +811,7 @@ async fn get_bundle_surfaces_auth_recovery_message() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
);
@@ -872,6 +878,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
);
+1
View File
@@ -52,6 +52,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option<String>) -> Option<AuthM
config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url.or(Some(config.chatgpt_base_url.clone())),
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
.await,
)
+7
View File
@@ -24,6 +24,13 @@ tracing-opentelemetry = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
zstd = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
sha2 = { workspace = true }
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_Networking_WinHttp",
] }
[lints]
workspace = true
+6
View File
@@ -3,6 +3,7 @@ mod chatgpt_hosts;
mod custom_ca;
mod default_client;
mod error;
mod outbound_proxy;
mod request;
mod retry;
mod sse;
@@ -25,6 +26,11 @@ pub use crate::default_client::CodexHttpClient;
pub use crate::default_client::CodexRequestBuilder;
pub use crate::error::StreamError;
pub use crate::error::TransportError;
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
pub use crate::outbound_proxy::ClientRouteClass;
pub use crate::outbound_proxy::OutboundProxyConfig;
pub use crate::outbound_proxy::RouteFailureClass;
pub use crate::outbound_proxy::build_reqwest_client_for_route;
pub use crate::request::EncodedJsonBody;
pub use crate::request::PreparedRequestBody;
pub use crate::request::Request;
+540
View File
@@ -0,0 +1,540 @@
//! Conservative outbound proxy selection for resolver-aware clients.
//!
//! When enabled, platform system discovery is tried first, explicit environment
//! proxies are the fallback, and the final fallback is a direct connection.
//! When disabled, callers retain the existing reqwest builder behavior.
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
use crate::custom_ca::BuildCustomCaTransportError;
use crate::custom_ca::build_reqwest_client_with_custom_ca;
#[cfg(target_os = "windows")]
use sha2::Digest;
#[cfg(target_os = "windows")]
use sha2::Sha256;
use thiserror::Error;
const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60);
const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5);
const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256;
#[cfg(target_os = "windows")]
mod windows;
/// Coarse semantic bucket for the HTTP or WebSocket client being constructed.
///
/// This is not the selected proxy route or a concrete endpoint. It labels the
/// product path that owns the client so proxy-resolution diagnostics can
/// distinguish auth, API, WebSocket, and miscellaneous traffic without exposing
/// endpoint details.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientRouteClass {
/// Login, token refresh/revoke, PAT, and agent identity auth traffic.
Auth,
/// First-party API traffic that is not part of the auth flow.
Api,
/// WebSocket traffic.
WebSocket,
/// Call sites without a more specific route class.
Other,
}
impl fmt::Display for ClientRouteClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auth => "auth",
Self::Api => "api",
Self::WebSocket => "wss",
Self::Other => "other",
})
}
}
/// Coarse failure class for route selection errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteFailureClass {
ProxyResolutionUnavailable,
ConnectTimeout,
ProxyAuthenticationRequired,
TlsError,
InvalidProxyConfig,
UnsupportedProxyScheme,
ResolverError,
}
impl fmt::Display for RouteFailureClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable",
Self::ConnectTimeout => "connect_timeout",
Self::ProxyAuthenticationRequired => "proxy_407",
Self::TlsError => "tls_error",
Self::InvalidProxyConfig => "invalid_proxy_config",
Self::UnsupportedProxyScheme => "unsupported_proxy_scheme",
Self::ResolverError => "resolver_error",
})
}
}
/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing.
/// Resolved endpoints and platform details remain internal to the client builder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutboundProxyConfig;
impl OutboundProxyConfig {
pub const fn respect_system_proxy() -> Self {
Self
}
}
/// Error while building a resolver-aware reqwest client.
#[derive(Debug, Error)]
pub enum BuildRouteAwareHttpClientError {
#[error(transparent)]
CustomCa(#[from] BuildCustomCaTransportError),
#[error("Failed to configure outbound proxy selected for {route_class}")]
InvalidProxyConfig { route_class: ClientRouteClass },
}
impl From<BuildRouteAwareHttpClientError> for io::Error {
fn from(error: BuildRouteAwareHttpClientError) -> Self {
match error {
BuildRouteAwareHttpClientError::CustomCa(error) => error.into(),
BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } => io::Error::other(error),
}
}
}
/// Builds a reqwest client with conservative route selection and shared CA handling.
///
/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after
/// a route is selected are returned without trying another route.
pub fn build_reqwest_client_for_route(
builder: reqwest::ClientBuilder,
request_url: &str,
route_class: ClientRouteClass,
config: Option<&OutboundProxyConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
let builder =
configure_proxy_for_route(&ProcessEnv, builder, request_url, route_class, config)?;
build_reqwest_client_with_custom_ca(builder).map_err(Into::into)
}
fn configure_proxy_for_route(
env: &dyn EnvSource,
builder: reqwest::ClientBuilder,
request_url: &str,
route_class: ClientRouteClass,
config: Option<&OutboundProxyConfig>,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
if config.is_none() {
return Ok(builder);
}
let origin = RequestOrigin::parse(request_url);
let Some(origin) = origin.as_ref() else {
return configure_env_proxy_handling(env, builder, /*origin*/ None, route_class);
};
match resolve_system_proxy(request_url, origin) {
SystemProxyDecision::Direct => Ok(builder.no_proxy()),
SystemProxyDecision::Proxy { url } => {
configure_concrete_proxy(builder, route_class, &url, /*no_proxy*/ None)
}
SystemProxyDecision::Unavailable { .. } => {
configure_env_proxy_handling(env, builder, Some(origin), route_class)
}
}
}
fn configure_concrete_proxy(
builder: reqwest::ClientBuilder,
route_class: ClientRouteClass,
proxy_url: &str,
no_proxy: Option<reqwest::NoProxy>,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
let proxy = match reqwest::Proxy::all(proxy_url) {
Ok(proxy) => proxy,
Err(_source) => {
return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class });
}
};
Ok(builder.proxy(proxy.no_proxy(no_proxy)))
}
fn configure_env_proxy_handling(
env: &dyn EnvSource,
builder: reqwest::ClientBuilder,
origin: Option<&RequestOrigin>,
route_class: ClientRouteClass,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
if let Some(origin) = origin {
let proxy_url = match origin.scheme.as_str() {
"https" => {
proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY"))
}
"http" => {
proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY"))
}
_ => proxy_env_value(env, "ALL_PROXY"),
};
if let Some(proxy_url) = proxy_url {
let no_proxy = proxy_env_value(env, "NO_PROXY")
.and_then(|value| reqwest::NoProxy::from_string(&value));
return configure_concrete_proxy(builder, route_class, &proxy_url, no_proxy);
}
}
Ok(builder.no_proxy())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
struct RequestOrigin {
scheme: String,
host: String,
port: u16,
}
impl RequestOrigin {
fn parse(request_url: &str) -> Option<Self> {
let uri = request_url.parse::<http::Uri>().ok()?;
let scheme = uri.scheme_str()?.to_ascii_lowercase();
let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase();
let port = uri.port_u16().or(match scheme.as_str() {
"http" => Some(80),
"https" => Some(443),
_ => None,
})?;
Some(Self { scheme, host, port })
}
}
#[cfg_attr(
not(target_os = "windows"),
allow(
dead_code,
reason = "Direct and Proxy are constructed only by platform-specific resolvers"
)
)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum SystemProxyDecision {
Direct,
Proxy { url: String },
Unavailable { failure: RouteFailureClass },
}
fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
if let Some(decision) = cached_system_proxy_decision(request_url) {
return decision;
}
let decision = resolve_platform_system_proxy(request_url, origin);
cache_system_proxy_decision(request_url, decision.clone());
decision
}
#[cfg(target_os = "windows")]
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
windows::resolve(request_url, origin)
}
#[cfg(not(target_os = "windows"))]
fn resolve_platform_system_proxy(
_request_url: &str,
_origin: &RequestOrigin,
) -> SystemProxyDecision {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
}
}
#[derive(Debug, Clone)]
struct CachedSystemProxyDecision {
decision: SystemProxyDecision,
expires_at: Instant,
}
static SYSTEM_PROXY_CACHE: OnceLock<Mutex<HashMap<String, CachedSystemProxyDecision>>> =
OnceLock::new();
fn cached_system_proxy_decision(request_url: &str) -> Option<SystemProxyDecision> {
let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().ok()?;
let key = system_proxy_cache_key(request_url);
let cached = cache.get(&key)?;
if cached.expires_at > Instant::now() {
return Some(cached.decision.clone());
}
cache.remove(&key);
None
}
fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) {
let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(mut cache) = cache.lock() {
let cache_key = system_proxy_cache_key(request_url);
insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now());
}
}
fn insert_system_proxy_cache_entry(
cache: &mut HashMap<String, CachedSystemProxyDecision>,
cache_key: &str,
decision: SystemProxyDecision,
now: Instant,
) {
let ttl = match &decision {
SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => {
SYSTEM_PROXY_SUCCESS_CACHE_TTL
}
SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL,
};
cache.retain(|_, cached| cached.expires_at > now);
if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES
&& !cache.contains_key(cache_key)
&& let Some(cache_key_to_evict) = cache
.iter()
.min_by_key(|(_, cached)| cached.expires_at)
.map(|(cache_key, _)| cache_key.clone())
{
cache.remove(&cache_key_to_evict);
}
cache.insert(
cache_key.to_string(),
CachedSystemProxyDecision {
decision,
expires_at: now + ttl,
},
);
}
fn system_proxy_cache_key(request_url: &str) -> String {
#[cfg(target_os = "windows")]
{
// Keep URL-specific PAC decisions without retaining the raw routed URL.
let mut hasher = Sha256::new();
hasher.update(b"system-proxy-cache-v1\0");
hasher.update(request_url.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(not(target_os = "windows"))]
request_url.to_string()
}
#[cfg(any(test, target_os = "windows"))]
fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool {
no_proxy
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.any(|entry| no_proxy_entry_matches_origin(entry, origin))
}
#[cfg(any(test, target_os = "windows"))]
fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool {
if entry == "*" {
return true;
}
let mut entry = entry
.strip_prefix("http://")
.or_else(|| entry.strip_prefix("https://"))
.unwrap_or(entry)
.trim_matches(['[', ']'])
.to_ascii_lowercase();
let mut port = None;
let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| {
if host.contains(':') {
return None;
}
candidate_port
.parse::<u16>()
.ok()
.map(|parsed_port| (host.to_string(), parsed_port))
});
if let Some((host, parsed_port)) = parsed_host_port {
entry = host;
port = Some(parsed_port);
}
if port.is_some_and(|port| port != origin.port) {
return false;
}
if let Some(suffix) = entry.strip_prefix('.') {
return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}"));
}
if entry.contains('*') {
return wildcard_host_match(&entry, &origin.host);
}
origin.host == entry
}
#[cfg(any(test, target_os = "windows"))]
fn wildcard_host_match(pattern: &str, host: &str) -> bool {
let mut remaining = host;
let mut first = true;
for part in pattern.split('*') {
if part.is_empty() {
continue;
}
if first && !pattern.starts_with('*') {
let Some(stripped) = remaining.strip_prefix(part) else {
return false;
};
remaining = stripped;
} else {
let Some(index) = remaining.find(part) else {
return false;
};
remaining = &remaining[index + part.len()..];
}
first = false;
}
pattern.ends_with('*') || remaining.is_empty()
}
#[cfg(any(test, target_os = "windows"))]
#[derive(Debug, Clone, PartialEq, Eq)]
enum ParsedProxyListDecision {
Direct,
Proxy(String),
UnsupportedScheme,
Unavailable,
}
#[cfg(any(test, target_os = "windows"))]
fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision {
let mut saw_unsupported = false;
{
let mut process_token = |token: &str| {
let decision = parse_proxy_token(token, target_scheme);
match decision {
ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct),
ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)),
ParsedProxyListDecision::UnsupportedScheme => {
saw_unsupported = true;
None
}
ParsedProxyListDecision::Unavailable => None,
}
};
for segment in input
.split(';')
.map(str::trim)
.filter(|segment| !segment.is_empty())
{
let mut parts = segment.split_whitespace();
let directive = parts.next();
let hostport = parts.next();
let extra = parts.next();
let is_proxy_directive = matches!(
directive.map(str::to_ascii_lowercase).as_deref(),
Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5")
) && hostport.is_some()
&& extra.is_none();
if is_proxy_directive {
if let Some(decision) = process_token(segment) {
return decision;
}
} else {
for token in segment.split_whitespace() {
if let Some(decision) = process_token(token) {
return decision;
}
}
}
}
}
if saw_unsupported {
ParsedProxyListDecision::UnsupportedScheme
} else {
ParsedProxyListDecision::Unavailable
}
}
#[cfg(any(test, target_os = "windows"))]
fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision {
if token.eq_ignore_ascii_case("DIRECT") {
return ParsedProxyListDecision::Direct;
}
if let Some(decision) = parse_proxy_key_token(token, target_scheme) {
return decision;
}
if token.contains('=') {
return ParsedProxyListDecision::Unavailable;
}
let mut parts = token.split_whitespace();
let directive = parts.next();
let hostport = parts.next();
if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) {
return match directive.to_ascii_lowercase().as_str() {
"proxy" | "http" => proxy_url_from_hostport("http", hostport),
"https" => proxy_url_from_hostport("https", hostport),
"socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme,
_ => ParsedProxyListDecision::Unavailable,
};
}
proxy_url_from_hostport("http", token)
}
#[cfg(any(test, target_os = "windows"))]
fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option<ParsedProxyListDecision> {
let (key, value) = token.split_once('=')?;
if key.trim().eq_ignore_ascii_case(target_scheme) {
Some(proxy_url_from_hostport("http", value.trim()))
} else {
Some(ParsedProxyListDecision::Unavailable)
}
}
#[cfg(any(test, target_os = "windows"))]
fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision {
if hostport.is_empty() {
return ParsedProxyListDecision::Unavailable;
}
if hostport.contains("://") {
return ParsedProxyListDecision::Proxy(hostport.to_string());
}
ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}"))
}
trait EnvSource {
fn var(&self, key: &str) -> Option<String>;
}
struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option<String> {
let lower = upper.to_ascii_lowercase();
env.var(upper)
.or_else(|| env.var(&lower))
.filter(|value| !value.is_empty())
}
#[cfg(test)]
#[path = "outbound_proxy_tests.rs"]
mod tests;
@@ -0,0 +1,362 @@
use std::ffi::c_void;
use std::ptr;
use super::ParsedProxyListDecision;
use super::RequestOrigin;
use super::RouteFailureClass;
use super::SystemProxyDecision;
use super::no_proxy_matches_origin;
use super::parse_proxy_list;
use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND;
use windows_sys::Win32::Foundation::FALSE;
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::Foundation::GlobalFree;
use windows_sys::Win32::Foundation::TRUE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_AUTODETECTION_FAILED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CANNOT_CONNECT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CONNECTION_ERROR;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_INVALID_URL;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_LOGIN_FAILURE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_NAME_NOT_RESOLVED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_CN_INVALID;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_DATE_INVALID;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REV_FAILED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REVOKED;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CHANNEL_ERROR;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_FAILURE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CA;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CERT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_TIMEOUT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE;
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNRECOGNIZED_SCHEME;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NAMED_PROXY;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NO_PROXY;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DHCP;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DNS_A;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_AUTO_DETECT;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_CONFIG_URL;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_OPTIONS;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_CURRENT_USER_IE_PROXY_CONFIG;
use windows_sys::Win32::Networking::WinHttp::WINHTTP_PROXY_INFO;
use windows_sys::Win32::Networking::WinHttp::WinHttpCloseHandle;
use windows_sys::Win32::Networking::WinHttp::WinHttpGetIEProxyConfigForCurrentUser;
use windows_sys::Win32::Networking::WinHttp::WinHttpGetProxyForUrl;
use windows_sys::Win32::Networking::WinHttp::WinHttpOpen;
use windows_sys::core::PWSTR;
pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
let ie_config = match current_user_ie_proxy_config() {
Ok(config) => config,
Err(failure) => {
return SystemProxyDecision::Unavailable { failure };
}
};
if let Some(pac_url) = ie_config.auto_config_url.as_deref() {
let decision = resolve_with_pac_url(request_url, origin, pac_url);
if !matches!(decision, SystemProxyDecision::Unavailable { .. }) {
return decision;
}
}
if ie_config.auto_detect {
let decision = resolve_with_auto_detect(request_url, origin);
if !matches!(decision, SystemProxyDecision::Unavailable { .. }) {
return decision;
}
}
if let Some(proxy) = ie_config.static_proxy.as_deref() {
if ie_config
.proxy_bypass
.as_deref()
.is_some_and(|bypass| proxy_bypass_matches_origin(bypass, origin))
{
return SystemProxyDecision::Direct;
}
return proxy_list_decision(proxy, origin);
}
if ie_config.auto_config_url.is_some() || ie_config.auto_detect {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
}
} else {
SystemProxyDecision::Direct
}
}
fn resolve_with_pac_url(
request_url: &str,
origin: &RequestOrigin,
pac_url: &str,
) -> SystemProxyDecision {
let pac_url = wide_null(pac_url);
let options = WINHTTP_AUTOPROXY_OPTIONS {
dwFlags: WINHTTP_AUTOPROXY_CONFIG_URL,
dwAutoDetectFlags: 0,
lpszAutoConfigUrl: pac_url.as_ptr(),
lpvReserved: ptr::null_mut(),
dwReserved: 0,
fAutoLogonIfChallenged: TRUE,
};
resolve_with_winhttp_options(request_url, origin, options)
}
fn resolve_with_auto_detect(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
let options = WINHTTP_AUTOPROXY_OPTIONS {
dwFlags: WINHTTP_AUTOPROXY_AUTO_DETECT,
dwAutoDetectFlags: WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A,
lpszAutoConfigUrl: ptr::null(),
lpvReserved: ptr::null_mut(),
dwReserved: 0,
fAutoLogonIfChallenged: FALSE,
};
resolve_with_winhttp_options(request_url, origin, options)
}
fn resolve_with_winhttp_options(
request_url: &str,
origin: &RequestOrigin,
mut options: WINHTTP_AUTOPROXY_OPTIONS,
) -> SystemProxyDecision {
let Some(session) = WinHttpSession::open() else {
return SystemProxyDecision::Unavailable {
failure: classify_winhttp_error(last_error()),
};
};
let request_url = wide_null(request_url);
let mut proxy_info = WINHTTP_PROXY_INFO {
dwAccessType: WINHTTP_ACCESS_TYPE_NO_PROXY,
lpszProxy: ptr::null_mut(),
lpszProxyBypass: ptr::null_mut(),
};
let ok = unsafe {
WinHttpGetProxyForUrl(
session.0,
request_url.as_ptr(),
&mut options,
&mut proxy_info,
)
};
if ok == FALSE {
return SystemProxyDecision::Unavailable {
failure: classify_winhttp_error(last_error()),
};
}
let proxy_info = ProxyInfo::from_raw(proxy_info);
if proxy_info.access_type == WINHTTP_ACCESS_TYPE_NO_PROXY {
return SystemProxyDecision::Direct;
}
if proxy_info.access_type != WINHTTP_ACCESS_TYPE_NAMED_PROXY {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
}
let Some(proxy) = proxy_info.proxy.as_deref() else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
};
proxy_list_decision(proxy, origin)
}
fn proxy_list_decision(proxy_list: &str, origin: &RequestOrigin) -> SystemProxyDecision {
match parse_proxy_list(proxy_list, &origin.scheme) {
ParsedProxyListDecision::Direct => SystemProxyDecision::Direct,
ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { url },
ParsedProxyListDecision::UnsupportedScheme => SystemProxyDecision::Unavailable {
failure: RouteFailureClass::UnsupportedProxyScheme,
},
ParsedProxyListDecision::Unavailable => SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
},
}
}
fn current_user_ie_proxy_config() -> Result<IeProxyConfig, RouteFailureClass> {
let mut raw = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG {
fAutoDetect: FALSE,
lpszAutoConfigUrl: ptr::null_mut(),
lpszProxy: ptr::null_mut(),
lpszProxyBypass: ptr::null_mut(),
};
let ok = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut raw) };
if ok == FALSE {
let error = last_error();
if error == ERROR_FILE_NOT_FOUND {
// Match WinHTTP's fallback by attempting WPAD when no IE proxy settings exist.
return Ok(IeProxyConfig {
auto_detect: true,
..Default::default()
});
}
return Err(classify_winhttp_error(error));
}
let auto_config_url = GlobalWideString::from_raw(raw.lpszAutoConfigUrl).into_string();
let static_proxy = GlobalWideString::from_raw(raw.lpszProxy).into_string();
let proxy_bypass = GlobalWideString::from_raw(raw.lpszProxyBypass).into_string();
Ok(IeProxyConfig {
auto_detect: raw.fAutoDetect != FALSE,
auto_config_url,
static_proxy,
proxy_bypass,
})
}
#[derive(Debug, Default)]
struct IeProxyConfig {
auto_detect: bool,
auto_config_url: Option<String>,
static_proxy: Option<String>,
proxy_bypass: Option<String>,
}
struct ProxyInfo {
access_type: u32,
proxy: Option<String>,
_proxy_bypass: Option<String>,
}
impl ProxyInfo {
fn from_raw(raw: WINHTTP_PROXY_INFO) -> Self {
Self {
access_type: raw.dwAccessType,
proxy: GlobalWideString::from_raw(raw.lpszProxy).into_string(),
_proxy_bypass: GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(),
}
}
}
struct GlobalWideString(PWSTR);
impl GlobalWideString {
fn from_raw(ptr: PWSTR) -> Self {
Self(ptr)
}
fn into_string(self) -> Option<String> {
if self.0.is_null() {
return None;
}
let string = unsafe { wide_ptr_to_string(self.0) };
if string.is_empty() {
None
} else {
Some(string)
}
}
}
impl Drop for GlobalWideString {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
GlobalFree(self.0.cast::<c_void>());
}
}
}
}
struct WinHttpSession(*mut c_void);
impl WinHttpSession {
fn open() -> Option<Self> {
let agent = wide_null("Codex");
let handle = unsafe {
WinHttpOpen(
agent.as_ptr(),
WINHTTP_ACCESS_TYPE_NO_PROXY,
ptr::null(),
ptr::null(),
0,
)
};
if handle.is_null() {
None
} else {
Some(Self(handle))
}
}
}
impl Drop for WinHttpSession {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
WinHttpCloseHandle(self.0);
}
}
}
}
fn proxy_bypass_matches_origin(proxy_bypass: &str, origin: &RequestOrigin) -> bool {
proxy_bypass
.split(|ch: char| ch == ';' || ch == ',' || ch.is_whitespace())
.map(str::trim)
.filter(|entry| !entry.is_empty())
.any(|entry| {
if entry.eq_ignore_ascii_case("<local>") {
!origin.host.contains('.')
} else {
no_proxy_matches_origin(entry, origin)
}
})
}
#[cfg(test)]
#[path = "windows_tests.rs"]
mod tests;
fn wide_null(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
unsafe fn wide_ptr_to_string(ptr: PWSTR) -> String {
let mut len = 0;
while unsafe { *ptr.add(len) } != 0 {
len += 1;
}
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
String::from_utf16_lossy(slice)
}
fn last_error() -> u32 {
unsafe { GetLastError() }
}
fn classify_winhttp_error(code: u32) -> RouteFailureClass {
match code {
ERROR_WINHTTP_TIMEOUT => RouteFailureClass::ConnectTimeout,
ERROR_WINHTTP_LOGIN_FAILURE => RouteFailureClass::ProxyAuthenticationRequired,
ERROR_WINHTTP_AUTODETECTION_FAILED
| ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT
| ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR
| ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT
| ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE => RouteFailureClass::ProxyResolutionUnavailable,
ERROR_WINHTTP_SECURE_CERT_CN_INVALID
| ERROR_WINHTTP_SECURE_CERT_DATE_INVALID
| ERROR_WINHTTP_SECURE_CERT_REVOKED
| ERROR_WINHTTP_SECURE_CERT_REV_FAILED
| ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE
| ERROR_WINHTTP_SECURE_CHANNEL_ERROR
| ERROR_WINHTTP_SECURE_FAILURE
| ERROR_WINHTTP_SECURE_INVALID_CA
| ERROR_WINHTTP_SECURE_INVALID_CERT => RouteFailureClass::TlsError,
ERROR_WINHTTP_INVALID_URL | ERROR_WINHTTP_UNRECOGNIZED_SCHEME => {
RouteFailureClass::InvalidProxyConfig
}
ERROR_WINHTTP_CANNOT_CONNECT
| ERROR_WINHTTP_CONNECTION_ERROR
| ERROR_WINHTTP_NAME_NOT_RESOLVED => RouteFailureClass::ResolverError,
_ => RouteFailureClass::ResolverError,
}
}
@@ -0,0 +1,18 @@
use super::*;
#[test]
fn proxy_bypass_matches_whitespace_separated_winhttp_entries() {
let local_origin = RequestOrigin {
scheme: "https".to_string(),
host: "intranet".to_string(),
port: 443,
};
assert!(proxy_bypass_matches_origin("<local> *.corp", &local_origin));
let corp_origin = RequestOrigin {
scheme: "https".to_string(),
host: "service.corp".to_string(),
port: 443,
};
assert!(proxy_bypass_matches_origin("<local> *.corp", &corp_origin));
}
@@ -0,0 +1,209 @@
use super::*;
use pretty_assertions::assert_eq;
use std::io::Read;
use std::io::Write;
struct MapEnv {
values: HashMap<String, String>,
}
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
}
#[test]
fn proxy_env_value_matches_reqwest_casing_precedence() {
let env = MapEnv {
values: HashMap::from([
("HTTPS_PROXY".to_string(), "upper".to_string()),
("https_proxy".to_string(), "lower".to_string()),
("http_proxy".to_string(), "lower-only".to_string()),
("ALL_PROXY".to_string(), String::new()),
("all_proxy".to_string(), "masked".to_string()),
]),
};
assert_eq!(
proxy_env_value(&env, "HTTPS_PROXY"),
Some("upper".to_string())
);
assert_eq!(
proxy_env_value(&env, "HTTP_PROXY"),
Some("lower-only".to_string())
);
assert_eq!(proxy_env_value(&env, "ALL_PROXY"), None);
}
#[test]
fn environment_fallback_reads_injected_proxy_environment() {
let env = MapEnv {
values: HashMap::from([("HTTPS_PROXY".to_string(), "://invalid".to_string())]),
};
let origin = RequestOrigin::parse("https://auth.openai.com/oauth/token").expect("valid URL");
let result = configure_env_proxy_handling(
&env,
reqwest::Client::builder(),
Some(&origin),
ClientRouteClass::Auth,
);
assert!(matches!(
result,
Err(BuildRouteAwareHttpClientError::InvalidProxyConfig {
route_class: ClientRouteClass::Auth,
})
));
}
#[tokio::test]
async fn enabled_environment_proxy_routes_request_through_proxy() {
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("local proxy listener should bind");
let proxy_addr = listener
.local_addr()
.expect("local proxy listener should have an address");
let proxy_thread = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("proxy should accept a request");
let mut buffer = [0_u8; 4096];
let size = stream.read(&mut buffer).expect("proxy should read request");
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
.expect("proxy should write response");
String::from_utf8_lossy(&buffer[..size]).into_owned()
});
let env = MapEnv {
values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]),
};
let request_url = "http://enabled-proxy.test/proxy-check";
let config = OutboundProxyConfig::respect_system_proxy();
let builder = configure_proxy_for_route(
&env,
reqwest::Client::builder().timeout(Duration::from_secs(2)),
request_url,
ClientRouteClass::Auth,
Some(&config),
)
.expect("enabled proxy route should configure");
let response = builder
.build()
.expect("proxy client should build")
.get(request_url)
.send()
.await
.expect("request should use local proxy");
let proxy_request = proxy_thread.join().expect("proxy thread should finish");
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
proxy_request.lines().next(),
Some("GET http://enabled-proxy.test/proxy-check HTTP/1.1")
);
}
#[test]
fn parses_pac_proxy_tokens() {
assert_eq!(
parse_proxy_list("PROXY proxy.internal:8080; DIRECT", "https"),
ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string())
);
assert_eq!(
parse_proxy_list("HTTPS proxy.internal:8443", "https"),
ParsedProxyListDecision::Proxy("https://proxy.internal:8443".to_string())
);
}
#[test]
fn unavailable_system_proxy_decision_is_cached() {
let request_url = "https://unavailable-cache.test/oauth/token";
let decision = SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
cache_system_proxy_decision(request_url, decision.clone());
assert_eq!(cached_system_proxy_decision(request_url), Some(decision));
}
#[test]
fn system_proxy_cache_is_bounded() {
let mut cache = HashMap::new();
let now = Instant::now();
for index in 0..=SYSTEM_PROXY_CACHE_MAX_ENTRIES {
insert_system_proxy_cache_entry(
&mut cache,
&format!("https://bounded-cache.test/{index}"),
SystemProxyDecision::Direct,
now,
);
}
assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES);
}
#[test]
fn parses_static_winhttp_proxy_entries_for_target_scheme() {
assert_eq!(
parse_proxy_list("http=web-proxy:8080;https=secure-proxy:8443", "https"),
ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string())
);
assert_eq!(
parse_proxy_list("http=web-proxy:8080 https=secure-proxy:8443", "https"),
ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string())
);
assert_eq!(
parse_proxy_list("http=web-proxy:8080", "https"),
ParsedProxyListDecision::Unavailable
);
assert_eq!(
parse_proxy_list("proxy.internal:8080", "https"),
ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string())
);
}
#[test]
fn reports_direct_and_unsupported_proxy_tokens() {
assert_eq!(
parse_proxy_list("DIRECT; PROXY proxy.internal:8080", "https"),
ParsedProxyListDecision::Direct
);
assert_eq!(
parse_proxy_list("DIRECT", "https"),
ParsedProxyListDecision::Direct
);
assert_eq!(
parse_proxy_list("SOCKS proxy.internal:1080", "https"),
ParsedProxyListDecision::UnsupportedScheme
);
}
#[test]
fn no_proxy_matches_exact_suffix_wildcard_and_port() {
let origin = RequestOrigin {
scheme: "https".to_string(),
host: "auth.openai.com".to_string(),
port: 443,
};
assert!(no_proxy_matches_origin("auth.openai.com", &origin));
assert!(!no_proxy_matches_origin("openai.com", &origin));
assert!(no_proxy_matches_origin(".openai.com", &origin));
assert!(no_proxy_matches_origin("*.openai.com", &origin));
assert!(no_proxy_matches_origin("auth.openai.com:443", &origin));
assert!(!no_proxy_matches_origin("auth.openai.com:8443", &origin));
}
#[test]
fn system_proxy_cache_key_preserves_url_specific_pac_decisions() {
let request_url = "https://auth.openai.com/oauth/token?access_token=secret";
let cache_key = system_proxy_cache_key(request_url);
assert_ne!(
cache_key,
system_proxy_cache_key("https://auth.openai.com/oauth/revoke")
);
#[cfg(target_os = "windows")]
assert!(!cache_key.contains(request_url));
}
@@ -212,6 +212,16 @@ pub async fn sync_remote_installed_plugin_bundles_once(
.map(str::trim)
.filter(|version| !version.is_empty());
if store.active_plugin_version(&plugin_id).as_deref() == release_version {
if let Err(err) = store.write_remote_plugin_id(&plugin_id, &plugin.id) {
warn!(
remote_plugin_id = %plugin.id,
plugin = %plugin.name,
marketplace = %marketplace_name,
error = %err,
"failed to persist identity for cached remote installed plugin"
);
failed_remote_plugin_ids.insert(plugin.id);
}
continue;
}
@@ -439,6 +449,13 @@ fn clear_remote_installed_plugin_bundle_sync_in_flight(key: &RemoteInstalledPlug
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
#[test]
fn remote_installed_plugin_sync_in_flight_dedupes_by_cache_root() {
@@ -461,6 +478,105 @@ mod tests {
clear_remote_installed_plugin_bundle_sync_in_flight(&key);
}
#[tokio::test]
async fn sync_backfills_remote_plugin_install_metadata_for_current_bundle() {
let server = MockServer::start().await;
let codex_home = tempfile::tempdir().expect("create codex home");
let cached_manifest = codex_home
.path()
.join(PLUGINS_CACHE_DIR)
.join(REMOTE_GLOBAL_MARKETPLACE_NAME)
.join("linear")
.join("1.2.3")
.join(".codex-plugin")
.join("plugin.json");
std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent"))
.expect("create cached plugin manifest parent");
std::fs::write(&cached_manifest, r#"{"name":"linear","version":"1.2.3"}"#)
.expect("write cached plugin manifest");
let remote_plugin_id = "plugins~Plugin_linear";
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "GLOBAL"))
.and(query_param("includeDownloadUrls", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [{
"id": remote_plugin_id,
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"version": "1.2.3",
"display_name": "Linear",
"description": "Track work",
"interface": {},
},
"enabled": true,
}],
"pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "USER"))
.and(query_param("includeDownloadUrls", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [],
"pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "WORKSPACE"))
.and(query_param("includeDownloadUrls", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [],
"pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&server)
.await;
let config = RemotePluginServiceConfig {
chatgpt_base_url: format!("{}/backend-api", server.uri()),
};
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let outcome = sync_remote_installed_plugin_bundles_once(
codex_home.path().to_path_buf(),
&config,
Some(&auth),
)
.await
.expect("sync current remote plugin bundle");
assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default());
let plugin_id = PluginId::new(
"linear".to_string(),
REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(),
)
.expect("valid plugin id");
let metadata_path = PluginStore::new(codex_home.path().to_path_buf())
.plugin_base_root(&plugin_id)
.join(".codex-remote-plugin-install.json");
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&std::fs::read_to_string(metadata_path.as_path())
.expect("read remote plugin install metadata")
)
.expect("parse remote plugin install metadata"),
json!({
"schema_version": 1,
"remote_plugin_id": remote_plugin_id,
})
);
}
#[test]
fn stale_remote_plugin_cleanup_skips_cache_mutations_in_progress() {
let codex_home = tempfile::tempdir().expect("create codex home");
+44 -2
View File
@@ -34,6 +34,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str =
pub struct ValidatedRemotePluginBundle {
pub plugin_id: PluginId,
pub plugin_version: String,
remote_plugin_id: String,
app_manifest: Option<JsonValue>,
bundle_download_url: String,
}
@@ -190,6 +191,7 @@ pub fn validate_remote_plugin_bundle(
Ok(ValidatedRemotePluginBundle {
plugin_id,
plugin_version,
remote_plugin_id: remote_plugin_id.to_string(),
app_manifest,
bundle_download_url,
})
@@ -403,9 +405,12 @@ fn install_remote_plugin_bundle(
})?;
let store = PluginStore::try_new(codex_home)?;
store
let remote_plugin_id = bundle.remote_plugin_id;
let result = store
.install_with_version(plugin_root, bundle.plugin_id, bundle.plugin_version)
.map_err(RemotePluginBundleInstallError::from)
.map_err(RemotePluginBundleInstallError::from)?;
store.write_remote_plugin_id(&result.plugin_id, &remote_plugin_id)?;
Ok(result)
}
fn extract_remote_plugin_bundle_to_path(
@@ -745,6 +750,43 @@ mod tests {
);
}
#[test]
fn install_persists_remote_plugin_install_metadata() {
let codex_home = tempdir().expect("tempdir");
let bundle = valid_remote_plugin_bundle();
let result = install_remote_plugin_bundle(
codex_home.path().to_path_buf(),
bundle,
tar_gz_bytes(&[(
".codex-plugin/plugin.json",
br#"{"name":"linear","version":"1.2.3"}"#,
/*mode*/ 0o644,
)]),
)
.expect("install bundle");
let store = PluginStore::new(codex_home.path().to_path_buf());
assert_eq!(
store.remote_plugin_id(&result.plugin_id).unwrap(),
Some(REMOTE_PLUGIN_ID.to_string())
);
let metadata_path = store
.plugin_base_root(&result.plugin_id)
.join(".codex-remote-plugin-install.json");
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&std::fs::read_to_string(metadata_path.as_path())
.expect("read remote plugin install metadata")
)
.expect("parse remote plugin install metadata"),
serde_json::json!({
"schema_version": 1,
"remote_plugin_id": REMOTE_PLUGIN_ID,
})
);
}
#[test]
fn install_preserves_non_global_bundle_manifest_metadata() {
let codex_home = tempdir().expect("tempdir");
+127
View File
@@ -7,16 +7,26 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::find_plugin_manifest_path;
use semver::Version;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::cmp::Ordering;
use std::fs;
use std::io;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
pub const PLUGINS_DATA_DIR: &str = "plugins/data";
const REMOTE_PLUGIN_INSTALL_METADATA_FILE: &str = ".codex-remote-plugin-install.json";
const REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Deserialize, Serialize)]
struct RemotePluginInstallMetadata {
schema_version: u8,
remote_plugin_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallResult {
@@ -106,6 +116,102 @@ impl PluginStore {
self.active_plugin_version(plugin_id).is_some()
}
pub fn remote_plugin_id(
&self,
plugin_id: &PluginId,
) -> Result<Option<String>, PluginStoreError> {
if !self.is_installed(plugin_id) {
return Ok(None);
}
let path = self.remote_plugin_install_metadata_path(plugin_id);
let contents = match fs::read_to_string(path.as_path()) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(PluginStoreError::io(
"failed to read remote plugin install metadata",
err,
));
}
};
let metadata: RemotePluginInstallMetadata =
serde_json::from_str(&contents).map_err(|err| {
PluginStoreError::Invalid(format!(
"failed to parse remote plugin install metadata: {err}"
))
})?;
if metadata.schema_version != REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION {
return Err(PluginStoreError::Invalid(format!(
"unsupported remote plugin install metadata schema version: {}",
metadata.schema_version
)));
}
let remote_plugin_id = metadata.remote_plugin_id.trim();
if remote_plugin_id.is_empty() {
return Err(PluginStoreError::Invalid(
"invalid remote plugin install metadata: remote plugin id must not be blank"
.to_string(),
));
}
Ok(Some(remote_plugin_id.to_string()))
}
pub fn write_remote_plugin_id(
&self,
plugin_id: &PluginId,
remote_plugin_id: &str,
) -> Result<(), PluginStoreError> {
if !self.is_installed(plugin_id) {
return Err(PluginStoreError::Invalid(format!(
"cannot write remote identity for uninstalled plugin `{}`",
plugin_id.as_key()
)));
}
let remote_plugin_id = remote_plugin_id.trim();
if remote_plugin_id.is_empty() {
return Err(PluginStoreError::Invalid(
"invalid remote plugin install metadata: remote plugin id must not be blank"
.to_string(),
));
}
let path = self.remote_plugin_install_metadata_path(plugin_id);
let parent = path.as_path().parent().ok_or_else(|| {
PluginStoreError::Invalid(format!(
"remote plugin install metadata path has no parent: {}",
path.display()
))
})?;
let mut contents = serde_json::to_vec_pretty(&RemotePluginInstallMetadata {
schema_version: REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION,
remote_plugin_id: remote_plugin_id.to_string(),
})
.map_err(|err| {
PluginStoreError::Invalid(format!(
"failed to serialize remote plugin install metadata: {err}"
))
})?;
contents.push(b'\n');
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|err| {
PluginStoreError::io(
"failed to create temporary remote plugin install metadata",
err,
)
})?;
temporary.write_all(&contents).map_err(|err| {
PluginStoreError::io("failed to write remote plugin install metadata", err)
})?;
temporary.as_file_mut().flush().map_err(|err| {
PluginStoreError::io("failed to flush remote plugin install metadata", err)
})?;
temporary.persist(path.as_path()).map_err(|err| {
PluginStoreError::io(
"failed to persist remote plugin install metadata",
err.error,
)
})?;
Ok(())
}
pub fn install(
&self,
source_path: AbsolutePathBuf,
@@ -197,6 +303,7 @@ impl PluginStore {
&plugin_version,
manifest,
)?;
self.remove_remote_plugin_install_metadata(&plugin_id)?;
Ok(PluginInstallResult {
plugin_id,
@@ -208,6 +315,26 @@ impl PluginStore {
pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
}
fn remote_plugin_install_metadata_path(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
self.plugin_base_root(plugin_id)
.join(REMOTE_PLUGIN_INSTALL_METADATA_FILE)
}
fn remove_remote_plugin_install_metadata(
&self,
plugin_id: &PluginId,
) -> Result<(), PluginStoreError> {
let path = self.remote_plugin_install_metadata_path(plugin_id);
match fs::remove_file(path.as_path()) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(PluginStoreError::io(
"failed to remove remote plugin install metadata",
err,
)),
}
}
}
#[derive(Debug, thiserror::Error)]
+106
View File
@@ -1,6 +1,7 @@
use super::*;
use codex_plugin::PluginId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::tempdir;
fn write_plugin_with_version(
@@ -191,6 +192,111 @@ fn install_with_version_uses_requested_cache_version() {
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn remote_plugin_install_metadata_follows_installed_cache_lifecycle() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let plugin_id = PluginId::new(
"sample-plugin".to_string(),
"openai-curated-remote".to_string(),
)
.unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
let source = AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap();
store
.install(source.clone(), plugin_id.clone())
.expect("install plugin");
assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None);
store
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample")
.expect("write remote identity");
let metadata_path = store.remote_plugin_install_metadata_path(&plugin_id);
assert_eq!(
metadata_path.as_path().file_name(),
Some(std::ffi::OsStr::new(".codex-remote-plugin-install.json"))
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&fs::read_to_string(metadata_path.as_path()).expect("read install metadata")
)
.expect("parse install metadata"),
json!({
"schema_version": 1,
"remote_plugin_id": "plugins~Plugin_sample",
})
);
assert_eq!(
store.remote_plugin_id(&plugin_id).unwrap(),
Some("plugins~Plugin_sample".to_string())
);
store
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_updated")
.expect("replace remote identity");
assert_eq!(
store.remote_plugin_id(&plugin_id).unwrap(),
Some("plugins~Plugin_updated".to_string())
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&fs::read_to_string(metadata_path.as_path()).expect("read updated install metadata")
)
.expect("parse updated install metadata"),
json!({
"schema_version": 1,
"remote_plugin_id": "plugins~Plugin_updated",
})
);
store
.install(source, plugin_id.clone())
.expect("replace with local install");
assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None);
assert!(!metadata_path.as_path().exists());
store
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample")
.expect("restore remote identity");
store.uninstall(&plugin_id).expect("uninstall plugin");
assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None);
assert!(!metadata_path.as_path().exists());
}
#[test]
fn remote_plugin_install_metadata_rejects_unsupported_schema_version() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let plugin_id = PluginId::new(
"sample-plugin".to_string(),
"openai-curated-remote".to_string(),
)
.unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
store
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.expect("install plugin");
fs::write(
store
.remote_plugin_install_metadata_path(&plugin_id)
.as_path(),
r#"{"schema_version":2,"remote_plugin_id":"plugins~Plugin_sample"}"#,
)
.expect("write unsupported install metadata");
let err = store
.remote_plugin_id(&plugin_id)
.expect_err("unsupported schema version should fail");
assert_eq!(
err.to_string(),
"unsupported remote plugin install metadata schema version: 2"
);
}
#[test]
fn install_prefers_on_disk_manifest_version_over_fallback() {
let tmp = tempdir().unwrap();
+13 -4
View File
@@ -449,6 +449,9 @@
"browser_use_external": {
"type": "boolean"
},
"browser_use_full_cdp_access": {
"type": "boolean"
},
"chronicle": {
"type": "boolean"
},
@@ -2674,10 +2677,13 @@
"minimum": 0.0,
"type": "number"
},
"reminder_interval_tokens": {
"format": "int64",
"minimum": 1.0,
"type": "integer"
"reminder_at_remaining_tokens": {
"description": "Remaining weighted-token values that trigger reminders when crossed.",
"items": {
"format": "int64",
"type": "integer"
},
"type": "array"
},
"sampling_token_weight": {
"format": "double",
@@ -4747,6 +4753,9 @@
"browser_use_external": {
"type": "boolean"
},
"browser_use_full_cdp_access": {
"type": "boolean"
},
"chronicle": {
"type": "boolean"
},
@@ -1,6 +1,8 @@
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConstraintError;
use crate::config::PermissionProfileCatalogEntry;
use crate::config::permission_profile_catalog;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::CloudConfigBundleLoadError;
@@ -1733,6 +1735,74 @@ managed-standard = true
Ok(())
}
#[tokio::test]
async fn permission_profile_catalog_marks_profiles_disallowed_by_requirements() -> anyhow::Result<()>
{
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_sandbox_modes = ["read-only", "workspace-write"]
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-standard = true
[permissions.managed-standard]
extends = ":workspace"
[permissions.managed-disabled]
extends = ":workspace"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.build()
.await?;
assert_eq!(
permission_profile_catalog(&config.config_layer_stack)?,
vec![
PermissionProfileCatalogEntry {
id: ":read-only".to_string(),
description: None,
allowed: false,
},
PermissionProfileCatalogEntry {
id: ":workspace".to_string(),
description: None,
allowed: false,
},
PermissionProfileCatalogEntry {
id: ":danger-full-access".to_string(),
description: None,
allowed: false,
},
PermissionProfileCatalogEntry {
id: "managed-disabled".to_string(),
description: None,
allowed: false,
},
PermissionProfileCatalogEntry {
id: "managed-standard".to_string(),
description: None,
allowed: true,
},
]
);
Ok(())
}
#[tokio::test]
async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()>
{
+6 -3
View File
@@ -552,7 +552,7 @@ async fn load_config_resolves_rollout_budget() -> std::io::Result<()> {
[features.rollout_budget]
enabled = true
limit_tokens = 100000
reminder_interval_tokens = 10000
reminder_at_remaining_tokens = [50000, 25000, 10000]
sampling_token_weight = 1.0
prefill_token_weight = 0.1
"#,
@@ -571,7 +571,7 @@ prefill_token_weight = 0.1
config.rollout_budget,
Some(RolloutBudgetConfig {
limit_tokens: 100_000,
reminder_interval_tokens: 10_000,
reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000],
sampling_token_weight: 1.0,
prefill_token_weight: 0.1,
})
@@ -3192,9 +3192,10 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
assert_eq!(
config.custom_permission_profiles,
vec![CustomPermissionProfileSummary {
vec![PermissionProfileCatalogEntry {
id: "dev".to_string(),
description: Some("Workspace access.".to_string()),
allowed: true,
}]
);
assert!(
@@ -9751,6 +9752,7 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> {
[features]
in_app_browser = false
browser_use = false
browser_use_full_cdp_access = false
"#,
),
)
@@ -9759,6 +9761,7 @@ browser_use = false
assert!(!config.features.enabled(Feature::InAppBrowser));
assert!(!config.features.enabled(Feature::BrowserUse));
assert!(!config.features.enabled(Feature::BrowserUseFullCdpAccess));
Ok(())
}
+64 -42
View File
@@ -72,6 +72,7 @@ use codex_features::TokenBudgetConfigToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_install_context::InstallContext;
use codex_login::AuthManagerConfig;
use codex_login::AuthRouteConfig;
use codex_mcp::McpConfig;
use codex_mcp::McpPluginAttribution;
use codex_mcp::McpServerRegistration;
@@ -147,6 +148,7 @@ pub mod edit;
mod managed_features;
mod network_proxy_spec;
mod otel;
mod permission_profile_catalog;
mod permissions;
mod resolved_permission_profile;
#[cfg(test)]
@@ -163,6 +165,11 @@ pub use codex_sandboxing::system_bwrap_warning;
pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permission_profile_catalog::PermissionProfileCatalogEntry;
pub use permission_profile_catalog::permission_profile_catalog;
use permission_profile_catalog::permission_profile_catalog_from_permissions;
use permission_profile_catalog::permission_profile_is_allowed;
use permission_profile_catalog::validate_permission_profile_for_deny_read;
pub(crate) use permissions::is_builtin_permission_profile_name;
pub(crate) use permissions::reject_unknown_builtin_permission_profile;
pub(crate) use permissions::resolve_permission_profile;
@@ -644,7 +651,7 @@ pub struct Config {
pub explicit_permission_profile_mode: bool,
/// User-defined permission profiles available from effective config.
pub custom_permission_profiles: Vec<CustomPermissionProfileSummary>,
pub custom_permission_profiles: Vec<PermissionProfileCatalogEntry>,
/// Configures who approval requests are routed to for review once they have
/// been escalated. This does not disable separate safety checks such as
@@ -1097,10 +1104,10 @@ impl Default for TokenBudgetConfig {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RolloutBudgetConfig {
pub limit_tokens: i64,
pub reminder_interval_tokens: i64,
pub reminder_at_remaining_tokens: Vec<i64>,
pub sampling_token_weight: f64,
pub prefill_token_weight: f64,
}
@@ -1201,6 +1208,10 @@ impl AuthManagerConfig for Config {
fn chatgpt_base_url(&self) -> String {
self.chatgpt_base_url.clone()
}
fn auth_route_config(&self) -> Option<AuthRouteConfig> {
Config::auth_route_config(self)
}
}
#[derive(Clone, Default)]
@@ -1454,6 +1465,11 @@ impl Config {
}
}
pub fn auth_route_config(&self) -> Option<AuthRouteConfig> {
self.respect_system_proxy
.then(AuthRouteConfig::respect_system_proxy)
}
/// Build the plugin-manager input from the effective config.
pub fn plugins_config_input(&self) -> PluginsConfigInput {
PluginsConfigInput::new(
@@ -2140,12 +2156,6 @@ pub struct AgentRoleConfig {
pub nickname_candidates: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomPermissionProfileSummary {
pub id: String,
pub description: Option<String>,
}
fn resolve_tool_suggest_config(
config_toml: &ConfigToml,
config_layer_stack: &ConfigLayerStack,
@@ -2596,13 +2606,23 @@ fn resolve_rollout_budget_config(
"features.rollout_budget.limit_tokens must be positive",
));
}
let reminder_interval_tokens = config
.reminder_interval_tokens
.unwrap_or_else(|| (limit_tokens / 10).max(1));
if reminder_interval_tokens <= 0 {
let reminder_at_remaining_tokens =
config
.reminder_at_remaining_tokens
.clone()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"features.rollout_budget.reminder_at_remaining_tokens is required when rollout_budget is enabled",
)
})?;
if reminder_at_remaining_tokens
.iter()
.any(|&tokens| tokens <= 0 || tokens >= limit_tokens)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"features.rollout_budget.reminder_interval_tokens must be positive",
"features.rollout_budget.reminder_at_remaining_tokens must contain only positive values below limit_tokens",
));
}
let sampling_token_weight = config.sampling_token_weight.unwrap_or(1.0);
@@ -2620,7 +2640,7 @@ fn resolve_rollout_budget_config(
}
Ok(Some(RolloutBudgetConfig {
limit_tokens,
reminder_interval_tokens,
reminder_at_remaining_tokens,
sampling_token_weight,
prefill_token_weight,
}))
@@ -2737,6 +2757,15 @@ pub fn resolve_bootstrap_respect_system_proxy(
Ok(features.get().enabled(Feature::RespectSystemProxy))
}
/// Resolves auth route settings for the initial cloud-config bootstrap.
pub fn resolve_bootstrap_auth_route_config(
cfg: &ConfigToml,
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<Option<AuthRouteConfig>> {
resolve_bootstrap_respect_system_proxy(cfg, feature_requirements)
.map(|enabled| enabled.then(AuthRouteConfig::respect_system_proxy))
}
pub(crate) fn resolve_web_search_mode_for_turn(
web_search_mode: &Constrained<WebSearchMode>,
permission_profile: &PermissionProfile,
@@ -3103,19 +3132,13 @@ impl Config {
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
);
let custom_permission_profiles = cfg
.permissions
.as_ref()
.map_or_else(Vec::new, |permissions| {
permissions
.entries
.iter()
.map(|(id, profile)| CustomPermissionProfileSummary {
id: id.clone(),
description: profile.description.clone(),
})
.collect()
});
let custom_permission_profiles = permission_profile_catalog_from_permissions(
&config_layer_stack,
effective_permission_selection.profiles.as_ref(),
)?
.into_iter()
.filter(|profile| !is_builtin_permission_profile_name(&profile.id))
.collect();
let using_implicit_builtin_profile = permission_config_syntax.is_none()
&& effective_permission_selection.selected_profile_id.is_none();
let should_seed_legacy_workspace_roots = effective_permission_selection
@@ -3627,20 +3650,10 @@ impl Config {
constrained_permission_profile
.value
.add_validator(move |permission_profile| {
let mode = sandbox_mode_requirement_for_permission_profile(permission_profile);
match mode {
SandboxModeRequirement::ReadOnly
| SandboxModeRequirement::WorkspaceWrite => Ok(()),
SandboxModeRequirement::DangerFullAccess
| SandboxModeRequirement::ExternalSandbox => {
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: format!("{mode:?}"),
allowed: "[read-only, workspace-write]".to_string(),
requirement_source: requirement_source.clone(),
})
}
}
validate_permission_profile_for_deny_read(
permission_profile,
&requirement_source,
)
})
.map_err(std::io::Error::from)?;
}
@@ -4093,6 +4106,15 @@ impl Config {
pub fn bundled_skills_enabled(&self) -> bool {
crate::skills::service::bundled_skills_enabled_from_stack(&self.config_layer_stack)
}
/// Returns whether effective requirements allow selecting a concrete profile.
pub fn is_permission_profile_allowed(
&self,
profile_id: &str,
permission_profile: &PermissionProfile,
) -> bool {
permission_profile_is_allowed(&self.config_layer_stack, profile_id, permission_profile)
}
}
fn guardian_policy_config_from_requirements(
@@ -0,0 +1,140 @@
use codex_config::ConfigLayerStack;
use codex_config::RequirementSource;
use codex_config::SandboxModeRequirement;
use codex_config::Sourced;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::sandbox_mode_requirement_for_permission_profile;
use codex_protocol::models::PermissionProfile;
use super::ConstraintError;
use super::ConstraintResult;
use super::is_permission_allowed;
use super::merge_managed_permission_profiles;
use super::permissions::BUILT_IN_DANGER_FULL_ACCESS_PROFILE;
use super::permissions::BUILT_IN_READ_ONLY_PROFILE;
use super::permissions::BUILT_IN_WORKSPACE_PROFILE;
use super::permissions::compile_permission_profile_selection;
use super::permissions::validate_user_permission_profile_names;
use super::validate_required_permission_profile_catalog;
/// A permission profile exposed to clients together with its effective availability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionProfileCatalogEntry {
pub id: String,
pub description: Option<String>,
pub allowed: bool,
}
/// Builds the effective permission profile catalog for a config layer stack.
pub fn permission_profile_catalog(
config_layer_stack: &ConfigLayerStack,
) -> std::io::Result<Vec<PermissionProfileCatalogEntry>> {
let permissions = config_layer_stack
.effective_config()
.get("permissions")
.cloned()
.map(toml::Value::try_into::<PermissionsToml>)
.transpose()
.map_err(std::io::Error::other)?;
let requirements_toml = config_layer_stack.requirements_toml();
let permissions = merge_managed_permission_profiles(permissions.as_ref(), requirements_toml)?;
permission_profile_catalog_from_permissions(config_layer_stack, permissions.as_ref())
}
pub(super) fn permission_profile_catalog_from_permissions(
config_layer_stack: &ConfigLayerStack,
permissions: Option<&PermissionsToml>,
) -> std::io::Result<Vec<PermissionProfileCatalogEntry>> {
let requirements_toml = config_layer_stack.requirements_toml();
validate_user_permission_profile_names(permissions)?;
validate_required_permission_profile_catalog(requirements_toml, permissions)?;
let mut catalog = [
(BUILT_IN_READ_ONLY_PROFILE, PermissionProfile::read_only()),
(
BUILT_IN_WORKSPACE_PROFILE,
PermissionProfile::workspace_write(),
),
(
BUILT_IN_DANGER_FULL_ACCESS_PROFILE,
PermissionProfile::Disabled,
),
]
.into_iter()
.map(|(id, permission_profile)| PermissionProfileCatalogEntry {
id: id.to_string(),
description: None,
allowed: permission_profile_is_allowed(config_layer_stack, id, &permission_profile),
})
.collect::<Vec<_>>();
if let Some(permissions) = permissions {
catalog.extend(permissions.entries.iter().map(|(id, profile)| {
let mut warnings = Vec::new();
let allowed = compile_permission_profile_selection(
Some(permissions),
id,
/*workspace_write*/ None,
&mut warnings,
)
.map(|(file_system, network)| {
PermissionProfile::from_runtime_permissions(&file_system, network)
})
.is_ok_and(|permission_profile| {
permission_profile_is_allowed(config_layer_stack, id, &permission_profile)
});
PermissionProfileCatalogEntry {
id: id.clone(),
description: profile.description.clone(),
allowed,
}
}));
}
Ok(catalog)
}
pub(super) fn permission_profile_is_allowed(
config_layer_stack: &ConfigLayerStack,
profile_id: &str,
permission_profile: &PermissionProfile,
) -> bool {
let allowed_by_id = config_layer_stack
.requirements_toml()
.allowed_permission_profiles
.as_ref()
.is_none_or(|allowed| is_permission_allowed(allowed, profile_id));
let allowed_by_sandbox_mode = config_layer_stack
.requirements()
.permission_profile
.can_set(permission_profile)
.is_ok();
let allowed_by_filesystem = config_layer_stack
.requirements()
.filesystem
.as_ref()
.is_none_or(|Sourced { value, source }| {
value.deny_read.is_empty()
|| validate_permission_profile_for_deny_read(permission_profile, source).is_ok()
});
allowed_by_id && allowed_by_sandbox_mode && allowed_by_filesystem
}
pub(super) fn validate_permission_profile_for_deny_read(
permission_profile: &PermissionProfile,
requirement_source: &RequirementSource,
) -> ConstraintResult<()> {
let mode = sandbox_mode_requirement_for_permission_profile(permission_profile);
match mode {
SandboxModeRequirement::ReadOnly | SandboxModeRequirement::WorkspaceWrite => Ok(()),
SandboxModeRequirement::DangerFullAccess | SandboxModeRequirement::ExternalSandbox => {
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: format!("{mode:?}"),
allowed: "[read-only, workspace-write]".to_string(),
requirement_source: requirement_source.clone(),
})
}
}
}
@@ -2,6 +2,9 @@ use super::ContextualUserFragment;
use codex_protocol::ThreadId;
use uuid::Uuid;
const CONTEXT_WINDOW_OPEN_TAG: &str = "<context_window>";
const CONTEXT_WINDOW_CLOSE_TAG: &str = "</context_window>";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TokenBudgetContext {
thread_id: ThreadId,
@@ -39,7 +42,7 @@ impl ContextualUserFragment for TokenBudgetContext {
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
(CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG)
}
fn body(&self) -> String {
@@ -57,7 +60,7 @@ impl ContextualUserFragment for TokenBudgetContext {
if let Some(mcp_result) = &self.mcp_result {
lines.push(mcp_result.clone());
}
lines.join("\n")
format!("\n{}\n", lines.join("\n"))
}
}
+1
View File
@@ -36,6 +36,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<personality_spec>",
// Keep recognizing token-budget wrappers persisted by older versions.
"<token_budget>",
"<context_window>",
"<rollout_budget>",
];
+12
View File
@@ -39,6 +39,18 @@ fn recognizes_legacy_token_budget_as_contextual_developer_content() {
assert!(!has_non_contextual_dev_message_content(&content));
}
#[test]
fn recognizes_context_window_as_contextual_developer_content() {
let content = vec![ContentItem::InputText {
text:
"<context_window>\nThread id 00000000-0000-0000-0000-000000000000.\n</context_window>"
.to_string(),
}];
assert!(is_contextual_dev_message_content(&content));
assert!(!has_non_contextual_dev_message_content(&content));
}
#[test]
fn parses_user_message_with_text_and_two_images() {
let img1 = "https://example.com/one.png".to_string();
+9 -5
View File
@@ -57,18 +57,22 @@ impl RolloutBudget {
window_id: &str,
) -> Option<RolloutBudgetReminder> {
let state = self.lock()?;
let reminder_index = (state.weighted_tokens_used
/ state.config.reminder_interval_tokens as f64)
let remaining_tokens = (state.config.limit_tokens as f64 - state.weighted_tokens_used)
.max(0.0)
.floor() as i64;
let reminder_index = state
.config
.reminder_at_remaining_tokens
.iter()
.filter(|&&threshold| remaining_tokens <= threshold)
.count() as i64;
if state.deliveries.get(&thread_id).is_some_and(|delivery| {
delivery.window_id.as_str() == window_id && delivery.reminder_index >= reminder_index
}) {
return None;
}
Some(RolloutBudgetReminder {
remaining_tokens: (state.config.limit_tokens as f64 - state.weighted_tokens_used)
.max(0.0)
.floor() as i64,
remaining_tokens,
reminder_index,
})
}
+2 -2
View File
@@ -255,7 +255,7 @@ mod tests {
.expect("token_budget should be enableable in tests");
config.rollout_budget = Some(crate::config::RolloutBudgetConfig {
limit_tokens: 100_000,
reminder_interval_tokens: 10_000,
reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000],
sampling_token_weight: 1.0,
prefill_token_weight: 0.25,
});
@@ -348,7 +348,7 @@ mod tests {
Some(FeatureToml::Config(RolloutBudgetConfigToml {
enabled: Some(true),
limit_tokens: Some(100_000),
reminder_interval_tokens: Some(10_000),
reminder_at_remaining_tokens: Some(vec![50_000, 25_000, 10_000]),
sampling_token_weight: Some(1.0),
prefill_token_weight: Some(0.25),
}))
+1 -1
View File
@@ -1024,7 +1024,7 @@ impl ThreadManager {
}
fn agent_control_for_config(&self, config: &Config) -> AgentControl {
AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget)
AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget.clone())
}
#[cfg(test)]
+1
View File
@@ -1385,6 +1385,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("Failed to load CodexAuth")
+19 -17
View File
@@ -25,12 +25,14 @@ use std::time::Duration;
use test_case::test_case;
use tokio::time::timeout;
const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig {
limit_tokens: 100,
reminder_interval_tokens: 25,
sampling_token_weight: 1.0,
prefill_token_weight: 1.0,
};
fn rollout_budget() -> RolloutBudgetConfig {
RolloutBudgetConfig {
limit_tokens: 100,
reminder_at_remaining_tokens: vec![75, 50, 25],
sampling_token_weight: 1.0,
prefill_token_weight: 1.0,
}
}
fn rollout_budget_texts(request: &ResponsesRequest) -> Vec<String> {
request
@@ -51,7 +53,7 @@ fn wire_request_contains(request: &wiremock::Request, text: &str) -> bool {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> {
async fn adds_weighted_initial_and_threshold_reminders() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -83,7 +85,7 @@ async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> {
config.rollout_budget = Some(RolloutBudgetConfig {
sampling_token_weight: 2.0,
prefill_token_weight: 0.5,
..ROLLOUT_BUDGET
..rollout_budget()
});
})
.build(&server)
@@ -171,7 +173,7 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> {
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow multi-agent v2");
config.rollout_budget = Some(ROLLOUT_BUDGET);
config.rollout_budget = Some(rollout_budget());
})
.build(&server)
.await?;
@@ -218,8 +220,8 @@ async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> {
.with_config(|config| {
config.rollout_budget = Some(RolloutBudgetConfig {
limit_tokens: 30,
reminder_interval_tokens: 10,
..ROLLOUT_BUDGET
reminder_at_remaining_tokens: vec![20, 10],
..rollout_budget()
});
})
.build(&server)
@@ -293,8 +295,8 @@ async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: b
config.model_provider = model_provider;
config.rollout_budget = Some(RolloutBudgetConfig {
limit_tokens: 10,
reminder_interval_tokens: 5,
..ROLLOUT_BUDGET
reminder_at_remaining_tokens: vec![5],
..rollout_budget()
});
if remote_v2 {
config
@@ -354,8 +356,8 @@ async fn restates_the_current_remainder_after_compaction() -> Result<()> {
.with_config(move |config| {
config.model_provider = model_provider;
config.rollout_budget = Some(RolloutBudgetConfig {
reminder_interval_tokens: 50,
..ROLLOUT_BUDGET
reminder_at_remaining_tokens: vec![50],
..rollout_budget()
});
})
.build(&server)
@@ -409,8 +411,8 @@ async fn restates_the_current_remainder_after_rollback() -> Result<()> {
let test = test_codex()
.with_config(|config| {
config.rollout_budget = Some(RolloutBudgetConfig {
reminder_interval_tokens: 50,
..ROLLOUT_BUDGET
reminder_at_remaining_tokens: vec![50],
..rollout_budget()
});
})
.build(&server)
@@ -1,6 +1,6 @@
---
source: core/tests/suite/token_budget.rs
assertion_line: 588
assertion_line: 545
expression: snapshot
---
Scenario: New context window tool installs fresh full context before the next follow-up request.
@@ -9,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo
00:message/developer[3]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <SKILLS_INSTRUCTIONS>
[03] Thread id <THREAD_ID>.\nFirst context window id: <FIRST_WINDOW_ID>\nCurrent context window id: <WINDOW_ID>\nPrevious context window id: <FIRST_WINDOW_ID>
[03] <context_window>\nThread id <THREAD_ID>.\nFirst context window id: <FIRST_WINDOW_ID>\nCurrent context window id: <WINDOW_ID>\nPrevious context window id: <FIRST_WINDOW_ID>\n</context_window>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:function_call/update_plan
03:function_call_output:Plan updated
+3 -3
View File
@@ -37,7 +37,7 @@ fn token_budget_contexts(request: &ResponsesRequest) -> Vec<String> {
request
.message_input_texts("developer")
.into_iter()
.filter(|text| text.starts_with("Thread id "))
.filter(|text| text.starts_with("<context_window>\nThread id "))
.collect()
}
@@ -47,7 +47,7 @@ fn token_budget_window_ids(
) -> (String, Option<String>, String) {
let captures = assert_regex_match(
&format!(
r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})(?:\nPrevious context window id: ([0-9a-f-]{{36}}))?$"
r"^<context_window>\nThread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})(?:\nPrevious context window id: ([0-9a-f-]{{36}}))?\n</context_window>$"
),
text,
);
@@ -191,7 +191,7 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> {
assert_eq!(token_budgets.len(), 1);
let captures = assert_regex_match(
&format!(
r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result$"
r"^<context_window>\nThread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n</context_window>$"
),
&token_budgets[0],
);
+12
View File
@@ -66,6 +66,7 @@ use codex_core::config::ConfigTomlLoadResult;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_toml_with_layer_stack;
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use codex_core::config::resolve_bootstrap_auth_route_config;
use codex_core::config::resolve_oss_provider;
use codex_core::config::resolve_profile_v2_config_path;
use codex_core::find_thread_meta_by_name_str;
@@ -349,6 +350,14 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let auth_route_config = resolve_bootstrap_auth_route_config(
bootstrap_config_toml,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)?;
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
@@ -357,6 +366,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
auth_route_config,
)
.await;
let run_cli_overrides = cli_kv_overrides.clone();
@@ -468,6 +478,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
set_default_client_residency_requirement(config.enforce_residency.value());
let auth_route_config = config.auth_route_config();
if let Err(err) = enforce_login_restrictions(&AuthConfig {
codex_home: config.codex_home.to_path_buf(),
auth_credentials_store_mode: config.cli_auth_credentials_store_mode,
@@ -475,6 +486,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
forced_login_method: config.forced_login_method,
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url: Some(config.chatgpt_base_url.clone()),
auth_route_config,
})
.await
{
+2 -2
View File
@@ -109,8 +109,8 @@ pub struct RolloutBudgetConfigToml {
#[schemars(range(min = 1))]
pub limit_tokens: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub reminder_interval_tokens: Option<i64>,
/// Remaining weighted-token values that trigger reminders when crossed.
pub reminder_at_remaining_tokens: Option<Vec<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0.0))]
pub sampling_token_weight: Option<f64>,
+10
View File
@@ -175,6 +175,10 @@ pub enum Feature {
///
/// Requirements-only gate: this should be set from requirements, not user config.
BrowserUse,
/// Allow Browser Use integration to access the full Chrome DevTools Protocol surface.
///
/// Requirements-only gate: this should be set from requirements, not user config.
BrowserUseFullCdpAccess,
/// Allow Browser Use integration with external browsers.
///
/// Requirements-only gate: this should be set from requirements, not user config.
@@ -1115,6 +1119,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::BrowserUseFullCdpAccess,
key: "browser_use_full_cdp_access",
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::BrowserUseExternal,
key: "browser_use_external",
+37 -14
View File
@@ -3,6 +3,9 @@ use std::sync::Arc;
use codex_agent_identity::AgentIdentityKey;
use codex_agent_identity::ChatGptEnvironment;
use codex_agent_identity::agent_identity_jwks_url;
use codex_agent_identity::agent_registration_url;
use codex_agent_identity::agent_task_registration_url;
use codex_agent_identity::build_abom;
use codex_agent_identity::decode_agent_identity_jwt;
use codex_agent_identity::fetch_agent_identity_jwks;
@@ -15,7 +18,8 @@ use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::SessionSource;
use thiserror::Error;
use crate::default_client::build_reqwest_client;
use crate::default_client::build_default_auth_reqwest_client;
use crate::outbound_proxy::AuthRouteConfig;
use super::storage::AgentIdentityAuthRecord;
@@ -86,7 +90,7 @@ pub struct AgentIdentityAuth {
pub(super) struct ManagedChatGptAgentIdentityBinding {
pub(super) account_id: String,
pub(super) chatgpt_user_id: String,
pub(super) email: String,
pub(super) email: Option<String>,
pub(super) plan_type: AccountPlanType,
pub(super) chatgpt_account_is_fedramp: bool,
pub(super) access_token: String,
@@ -96,13 +100,18 @@ impl AgentIdentityAuth {
pub async fn from_record(
mut record: AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key)
.map_err(std::io::Error::other)?;
if record_needs_task_registration(&record) {
record.task_id = Some(
register_task_for_record_with_retries(&record, agent_identity_authapi_base_url)
.await?,
register_task_for_record_with_retries(
&record,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?,
);
}
Ok(Self {
@@ -114,9 +123,10 @@ impl AgentIdentityAuth {
jwt: &str,
chatgpt_base_url: &str,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?;
Self::from_record(record, agent_identity_authapi_base_url).await
let record = verified_record_from_jwt(jwt, chatgpt_base_url, auth_route_config).await?;
Self::from_record(record, agent_identity_authapi_base_url, auth_route_config).await
}
#[cfg(test)]
@@ -146,8 +156,8 @@ impl AgentIdentityAuth {
&self.record.chatgpt_user_id
}
pub fn email(&self) -> &str {
&self.record.email
pub fn email(&self) -> Option<&str> {
self.record.email.as_deref()
}
pub fn plan_type(&self) -> AccountPlanType {
@@ -163,9 +173,11 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
binding: ManagedChatGptAgentIdentityBinding,
agent_identity_authapi_base_url: &str,
session_source: SessionSource,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<AgentIdentityAuth> {
let key_material = generate_agent_key_material().map_err(std::io::Error::other)?;
let client = build_reqwest_client();
let registration_url = agent_registration_url(agent_identity_authapi_base_url);
let client = build_default_auth_reqwest_client(&registration_url, auth_route_config)?;
let runtime_id = retry_registration(|| async {
register_agent_identity(
&client,
@@ -200,7 +212,7 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp,
task_id: None,
};
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))
}
@@ -208,9 +220,12 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
pub(super) async fn verified_record_from_jwt(
jwt: &str,
chatgpt_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<AgentIdentityAuthRecord> {
AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url)
let jwks_url = agent_identity_jwks_url(chatgpt_base_url);
let client = build_default_auth_reqwest_client(&jwks_url, auth_route_config)?;
let jwks = fetch_agent_identity_jwks(&client, chatgpt_base_url)
.await
.map_err(std::io::Error::other)?;
let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?;
@@ -285,19 +300,24 @@ where
async fn register_task_for_record_with_retries(
record: &AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<String> {
let task_registration_url =
agent_task_registration_url(agent_identity_authapi_base_url, &record.agent_runtime_id);
let client = build_default_auth_reqwest_client(&task_registration_url, auth_route_config)?;
retry_registration(|| async {
register_task_for_record(record, agent_identity_authapi_base_url).await
register_task_for_record(&client, record, agent_identity_authapi_base_url).await
})
.await
}
async fn register_task_for_record(
client: &reqwest::Client,
record: &AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
) -> std::io::Result<String> {
register_agent_task(
&build_reqwest_client(),
client,
agent_identity_authapi_base_url,
key_for_record(record),
)
@@ -343,7 +363,7 @@ mod tests {
agent_private_key: private_key,
account_id: "account-1".to_string(),
chatgpt_user_id: "user-1".to_string(),
email: "agent@example.com".to_string(),
email: Some("agent@example.com".to_string()),
plan_type: AccountPlanType::Plus,
chatgpt_account_is_fedramp: false,
task_id: None,
@@ -370,6 +390,7 @@ mod tests {
let auth = AgentIdentityAuth::from_record(
agent_identity_record_with_generated_key(),
&server.uri(),
/*auth_route_config*/ None,
)
.await?;
@@ -414,6 +435,7 @@ mod tests {
&jwt,
&format!("{}/backend-api", server.uri()),
&server.uri(),
/*auth_route_config*/ None,
)
.await?;
@@ -456,6 +478,7 @@ mod tests {
let auth = AgentIdentityAuth::from_record(
agent_identity_record_with_generated_key(),
&server.uri(),
/*auth_route_config*/ None,
)
.await?;
+47 -2
View File
@@ -122,6 +122,7 @@ async fn login_with_access_token_writes_agent_identity_jwt() {
/*forced_chatgpt_workspace_id*/ None,
Some(&chatgpt_base_url),
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("login_with_access_token should succeed");
@@ -181,6 +182,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result
Some(&chatgpt_base_url),
AuthKeyringBackendKind::Direct,
Some(&authapi_base_url),
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -226,6 +228,7 @@ async fn login_with_access_token_writes_only_personal_access_token() {
Some(&allowed_workspaces),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("personal access token login should succeed");
@@ -278,6 +281,7 @@ async fn login_with_access_token_rejects_personal_access_token_workspace_mismatc
Some(&allowed_workspaces),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("personal access token workspace mismatch should fail");
@@ -310,6 +314,7 @@ async fn login_with_access_token_rejects_invalid_personal_access_token() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("invalid personal access token should fail");
@@ -333,6 +338,7 @@ async fn login_with_access_token_rejects_invalid_jwt() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("invalid access token should fail");
@@ -364,6 +370,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -373,6 +380,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::JwtOnly,
/*agent_identity_authapi_base_url*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -403,6 +411,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -412,6 +421,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -442,6 +452,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should reload");
@@ -450,6 +461,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -482,6 +494,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow:
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -510,6 +523,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow:
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -546,6 +560,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -563,6 +578,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await
@@ -591,7 +607,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
)?;
let mut record = agent_identity_record("account-123");
record.chatgpt_user_id = "user-12345".to_string();
record.email = "user@example.com".to_string();
record.email = Some("user@example.com".to_string());
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_path = get_auth_file(codex_home.path());
let mut auth_json = storage.try_read_auth_json(&auth_path)?;
@@ -605,6 +621,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -625,6 +642,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await
@@ -659,6 +677,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -676,6 +695,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await
@@ -717,6 +737,7 @@ async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> an
&agent_identity,
Some(&chatgpt_base_url),
&authapi_base_url,
/*auth_route_config*/ None,
)
.await
.expect_err("agent identity jwt task retry exhaustion should fail");
@@ -747,6 +768,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() {
/*forced_chatgpt_workspace_id*/ None,
Some(&chatgpt_base_url),
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("unsigned access token should fail");
@@ -768,6 +790,7 @@ async fn missing_auth_json_returns_none() {
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("call should succeed");
@@ -797,6 +820,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.unwrap()
@@ -858,6 +882,7 @@ async fn loads_api_key_from_auth_json() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.unwrap()
@@ -908,6 +933,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let managed = UnauthorizedRecovery {
@@ -952,6 +978,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -972,6 +999,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("updated auth should parse");
@@ -1277,6 +1305,7 @@ async fn build_config(
forced_login_method,
forced_chatgpt_workspace_id,
chatgpt_base_url: None,
auth_route_config: None,
}
}
@@ -1359,6 +1388,7 @@ async fn load_auth_reads_access_token_from_env() {
Some(&chatgpt_base_url),
AuthKeyringBackendKind::Direct,
Some(&authapi_base_url),
/*auth_route_config*/ None,
)
.await
.expect("env auth should load")
@@ -1406,6 +1436,7 @@ async fn load_auth_reads_personal_access_token_from_env() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("env auth should load")
@@ -1459,6 +1490,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() {
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -1498,6 +1530,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch()
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("personal access token login should succeed");
@@ -1509,6 +1542,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch()
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -1542,6 +1576,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
);
@@ -1574,6 +1609,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("env auth should load")
@@ -1670,6 +1706,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("personal access token login should succeed");
@@ -1681,6 +1718,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
chatgpt_base_url: None,
auth_route_config: None,
};
let err = super::enforce_login_restrictions(&config)
@@ -1804,6 +1842,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
chatgpt_base_url: Some(chatgpt_base_url),
auth_route_config: None,
};
let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url(
@@ -1887,7 +1926,7 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
agent_private_key: key_material.private_key_pkcs8_base64,
account_id: account_id.to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: AccountPlanType::Pro,
chatgpt_account_is_fedramp: false,
task_id: None,
@@ -2056,6 +2095,7 @@ async fn assert_agent_identity_plan_alias(
&jwt,
Some(&chatgpt_base_url),
&authapi_base_url,
/*auth_route_config*/ None,
)
.await
.expect("agent identity auth");
@@ -2087,6 +2127,7 @@ async fn plan_type_maps_known_plan() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2118,6 +2159,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2152,6 +2194,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2186,6 +2229,7 @@ async fn plan_type_maps_unknown_to_unknown() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2217,6 +2261,7 @@ async fn missing_plan_type_maps_to_unknown() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -63,6 +63,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()>
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -111,6 +112,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -135,6 +137,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()>
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
+59 -2
View File
@@ -5,8 +5,11 @@
//! workspace.
use codex_client::BuildCustomCaTransportError;
use codex_client::BuildRouteAwareHttpClientError;
use codex_client::ClientRouteClass;
use codex_client::CodexHttpClient;
pub use codex_client::CodexRequestBuilder;
use codex_client::build_reqwest_client_for_route;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::with_chatgpt_cloudflare_cookie_store;
use codex_terminal_detection::user_agent;
@@ -17,6 +20,8 @@ use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::RwLock;
use crate::outbound_proxy::AuthRouteConfig;
/// Set this to add a suffix to the User-Agent string.
///
/// It is not ideal that we're using a global singleton for this.
@@ -189,6 +194,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String {
}
/// Create an HTTP client with default `originator` and `User-Agent` headers set.
///
/// This supported default path preserves reqwest's existing proxy behavior and does not opt into
/// Codex's route-aware system/PAC resolution.
pub fn create_client() -> CodexHttpClient {
let inner = build_reqwest_client();
CodexHttpClient::new(inner)
@@ -200,6 +208,10 @@ pub fn create_client() -> CodexHttpClient {
/// policy, then layers in shared custom CA handling from `CODEX_CA_CERTIFICATE` /
/// `SSL_CERT_FILE`. The function remains infallible for compatibility with existing call sites, so
/// a custom-CA or builder failure is logged and falls back to `reqwest::Client::new()`.
///
/// This supported default path preserves reqwest's existing proxy behavior and does not opt into
/// Codex's route-aware system/PAC resolution. Auth callers with route settings must use
/// `build_default_auth_reqwest_client` or `create_default_auth_client`.
pub fn build_reqwest_client() -> reqwest::Client {
try_build_reqwest_client().unwrap_or_else(|error| {
tracing::warn!(error = %error, "failed to build default reqwest client");
@@ -220,13 +232,58 @@ pub fn build_reqwest_client() -> reqwest::Client {
/// Callers that need a structured CA-loading failure instead of the legacy logged fallback can use
/// this method directly.
pub fn try_build_reqwest_client() -> Result<reqwest::Client, BuildCustomCaTransportError> {
build_reqwest_client_with_custom_ca(default_reqwest_client_builder())
}
fn default_reqwest_client_builder() -> reqwest::ClientBuilder {
let mut builder = reqwest::Client::builder().default_headers(default_headers());
if is_sandboxed() {
builder = builder.no_proxy();
}
builder = with_chatgpt_cloudflare_cookie_store(builder);
with_chatgpt_cloudflare_cookie_store(builder)
}
build_reqwest_client_with_custom_ca(builder)
/// Builds a raw reqwest client for an auth endpoint without Codex default headers.
pub(crate) fn build_raw_auth_reqwest_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
build_reqwest_client_for_route(
reqwest::Client::builder(),
endpoint,
ClientRouteClass::Auth,
auth_route_config.map(AuthRouteConfig::route_config),
)
}
/// Builds the default Codex reqwest client for an auth endpoint.
pub(crate) fn build_default_auth_reqwest_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else {
return Ok(build_reqwest_client());
};
if is_sandboxed() {
// Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed
// separately through network-proxy.
return Ok(build_reqwest_client());
}
build_reqwest_client_for_route(
default_reqwest_client_builder(),
endpoint,
ClientRouteClass::Auth,
Some(route_config),
)
}
/// Builds the default Codex HTTP client wrapper for an auth endpoint.
pub(crate) fn create_default_auth_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<CodexHttpClient, BuildRouteAwareHttpClientError> {
build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new)
}
pub fn default_headers() -> HeaderMap {
+80 -20
View File
@@ -49,6 +49,8 @@ use crate::auth::storage::AuthStorageBackend;
use crate::auth::storage::create_auth_storage;
use crate::auth::util::try_parse_error_message;
use crate::default_client::create_client;
use crate::default_client::create_default_auth_client;
use crate::outbound_proxy::AuthRouteConfig;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_jwt_claims;
use crate::token_data::parse_jwt_expiration;
@@ -237,6 +239,7 @@ impl CodexAuth {
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
agent_identity_authapi_base_url: Option<&str>,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let auth_mode = auth_dot_json.resolved_mode();
if auth_mode == ApiAuthMode::ApiKey {
@@ -263,14 +266,18 @@ impl CodexAuth {
&jwt,
&base_url,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
return Ok(Self::AgentIdentity(auth));
}
AgentIdentityStorage::Record(record) => {
let auth =
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
.await?;
let auth = AgentIdentityAuth::from_record(
record,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
return Ok(Self::AgentIdentity(auth));
}
}
@@ -281,7 +288,8 @@ impl CodexAuth {
"personal access token auth is missing a personal access token.",
));
};
return Self::from_personal_access_token(personal_access_token).await;
return Self::from_personal_access_token(personal_access_token, auth_route_config)
.await;
}
if auth_mode == ApiAuthMode::BedrockApiKey {
let Some(auth) = auth_dot_json.bedrock_api_key else {
@@ -293,9 +301,10 @@ impl CodexAuth {
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?;
let state = ChatgptAuthState {
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
client: create_client(),
client,
};
match auth_mode {
@@ -324,6 +333,7 @@ impl CodexAuth {
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Option<Self>> {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url).ok();
@@ -335,6 +345,7 @@ impl CodexAuth {
chatgpt_base_url,
keyring_backend_kind,
agent_identity_authapi_base_url.as_deref(),
auth_route_config,
)
.await
}
@@ -342,12 +353,14 @@ impl CodexAuth {
pub async fn from_agent_identity_jwt(
jwt: &str,
chatgpt_base_url: Option<&str>,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?;
Self::from_agent_identity_jwt_with_authapi_base_url(
jwt,
chatgpt_base_url,
&agent_identity_authapi_base_url,
auth_route_config,
)
.await
}
@@ -356,19 +369,29 @@ impl CodexAuth {
jwt: &str,
chatgpt_base_url: Option<&str>,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let base_url = chatgpt_base_url
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
.to_string();
Ok(Self::AgentIdentity(
AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?,
AgentIdentityAuth::from_jwt(
jwt,
&base_url,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?,
))
}
pub async fn from_personal_access_token(access_token: &str) -> std::io::Result<Self> {
pub async fn from_personal_access_token(
access_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
Ok(Self::PersonalAccessToken(
PersonalAccessTokenAuth::load(access_token).await?,
PersonalAccessTokenAuth::load(access_token, auth_route_config).await?,
))
}
@@ -483,8 +506,8 @@ impl CodexAuth {
/// Returns `None` if Codex backend auth does not expose an account email.
pub fn get_account_email(&self) -> Option<String> {
match self {
Self::AgentIdentity(auth) => Some(auth.email().to_string()),
Self::PersonalAccessToken(auth) => Some(auth.email().to_string()),
Self::AgentIdentity(auth) => auth.email().map(str::to_string),
Self::PersonalAccessToken(auth) => auth.email().map(str::to_string),
_ => self.get_current_token_data().and_then(|t| t.id_token.email),
}
}
@@ -568,6 +591,7 @@ impl CodexAuth {
policy: AgentIdentityAuthPolicy,
agent_identity_authapi_base_url: Option<&str>,
forced_chatgpt_workspace_id: Option<Vec<String>>,
auth_route_config: Option<&AuthRouteConfig>,
session_source: SessionSource,
) -> std::io::Result<Option<AgentIdentityAuth>> {
match self {
@@ -583,6 +607,7 @@ impl CodexAuth {
self.ensure_managed_chatgpt_agent_identity(
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
forced_chatgpt_workspace_id,
auth_route_config,
session_source,
)
.await
@@ -595,6 +620,7 @@ impl CodexAuth {
&self,
agent_identity_authapi_base_url: &str,
forced_chatgpt_workspace_id: Option<Vec<String>>,
auth_route_config: Option<&AuthRouteConfig>,
session_source: SessionSource,
) -> std::io::Result<AgentIdentityAuth> {
let binding =
@@ -607,9 +633,13 @@ impl CodexAuth {
&& record_matches_managed_chatgpt_binding(&record, &binding)
{
let should_persist = record_needs_task_registration(&record);
let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))?;
let auth = AgentIdentityAuth::from_record(
record,
agent_identity_authapi_base_url,
auth_route_config,
)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))?;
if should_persist {
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
}
@@ -620,6 +650,7 @@ impl CodexAuth {
binding,
agent_identity_authapi_base_url,
session_source,
auth_route_config,
)
.await?;
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
@@ -692,7 +723,7 @@ impl ManagedChatGptAgentIdentityBinding {
Some(Self {
account_id,
chatgpt_user_id,
email: token_data.id_token.email.clone().unwrap_or_default(),
email: token_data.id_token.email.clone(),
plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown),
chatgpt_account_is_fedramp: auth.is_fedramp_account(),
access_token: token_data.access_token,
@@ -789,6 +820,7 @@ pub async fn logout_with_revoke(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<bool> {
let auth_dot_json = match load_auth_dot_json(
codex_home,
@@ -801,7 +833,7 @@ pub async fn logout_with_revoke(
None
}
};
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await {
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await {
tracing::warn!("failed to revoke auth tokens during logout: {err}");
}
logout_all_stores(
@@ -843,10 +875,11 @@ pub async fn login_with_access_token(
forced_chatgpt_workspace_id: Option<&[String]>,
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<()> {
let auth_dot_json = match classify_codex_access_token(access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
let auth = PersonalAccessTokenAuth::load(access_token).await?;
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
AuthDotJson {
// Infer PAT auth from the credential field so older Codex builds can still
@@ -865,7 +898,7 @@ pub async fn login_with_access_token(
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
.to_string();
verified_record_from_jwt(jwt, &base_url).await?;
verified_record_from_jwt(jwt, &base_url, auth_route_config).await?;
AuthDotJson {
auth_mode: Some(ApiAuthMode::AgentIdentity),
openai_api_key: None,
@@ -954,8 +987,10 @@ pub struct AuthConfig {
pub forced_login_method: Option<ForcedLoginMethod>,
pub chatgpt_base_url: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
pub auth_route_config: Option<AuthRouteConfig>,
}
/// Enforces configured login restrictions using auth-owned HTTP settings.
pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok();
@@ -978,6 +1013,7 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url(
config.chatgpt_base_url.as_deref(),
config.keyring_backend_kind,
agent_identity_authapi_base_url,
config.auth_route_config.as_ref(),
)
.await?
else {
@@ -1113,6 +1149,7 @@ fn logout_all_stores(
Ok(removed_ephemeral || removed_managed)
}
#[allow(clippy::too_many_arguments)]
async fn load_auth(
codex_home: &Path,
enable_codex_api_key_env: bool,
@@ -1121,6 +1158,7 @@ async fn load_auth(
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
agent_identity_authapi_base_url: Option<&str>,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Option<CodexAuth>> {
// API key via env var takes precedence over any other auth method.
if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
@@ -1142,6 +1180,7 @@ async fn load_auth(
chatgpt_base_url,
keyring_backend_kind,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
@@ -1153,7 +1192,7 @@ async fn load_auth(
if let Some(access_token) = read_codex_access_token_from_env() {
return match classify_codex_access_token(&access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
let auth = PersonalAccessTokenAuth::load(access_token).await?;
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
Ok(Some(CodexAuth::PersonalAccessToken(auth)))
}
@@ -1162,6 +1201,7 @@ async fn load_auth(
jwt,
chatgpt_base_url,
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
auth_route_config,
)
}
.await
@@ -1192,6 +1232,7 @@ async fn load_auth(
chatgpt_base_url,
keyring_backend_kind,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
@@ -1237,7 +1278,6 @@ async fn request_chatgpt_token_refresh(
grant_type: "refresh_token",
refresh_token,
};
let endpoint = refresh_token_endpoint();
// Use shared client factory to include standard headers
@@ -1702,6 +1742,7 @@ pub struct AuthManager {
refresh_lock: Semaphore,
agent_identity_lock: Semaphore,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
auth_route_config: Option<AuthRouteConfig>,
}
/// Configuration view required to construct a shared [`AuthManager`].
@@ -1725,6 +1766,9 @@ pub trait AuthManagerConfig {
/// Returns the ChatGPT backend base URL used for first-party backend authorization.
fn chatgpt_base_url(&self) -> String;
/// Returns route-selection settings for auth-owned clients.
fn auth_route_config(&self) -> Option<AuthRouteConfig>;
}
impl Debug for AuthManager {
@@ -1743,6 +1787,7 @@ impl Debug for AuthManager {
&self.forced_chatgpt_workspace_id,
)
.field("chatgpt_base_url", &self.chatgpt_base_url)
.field("auth_route_config", &self.auth_route_config)
.field("has_external_auth", &self.has_external_auth())
.finish_non_exhaustive()
}
@@ -1764,6 +1809,7 @@ impl AuthManager {
forced_chatgpt_workspace_id: Option<Vec<String>>,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> Self {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok();
@@ -1775,6 +1821,7 @@ impl AuthManager {
chatgpt_base_url.as_deref(),
keyring_backend_kind,
agent_identity_authapi_base_url.as_deref(),
auth_route_config.as_ref(),
)
.await
.ok()
@@ -1796,6 +1843,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config,
}
}
@@ -1820,6 +1868,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config: None,
})
}
@@ -1843,6 +1892,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config: None,
})
}
@@ -1874,6 +1924,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config: None,
})
}
@@ -1897,6 +1948,7 @@ impl AuthManager {
external_auth: RwLock::new(Some(
Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
)),
auth_route_config: None,
})
}
@@ -1958,6 +2010,7 @@ impl AuthManager {
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
self.auth_route_config.as_ref(),
session_source,
)
.await;
@@ -1966,6 +2019,7 @@ impl AuthManager {
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
self.auth_route_config.as_ref(),
session_source,
)
.await
@@ -2074,6 +2128,7 @@ impl AuthManager {
self.chatgpt_base_url.as_deref(),
self.keyring_backend_kind,
self.agent_identity_authapi_base_url.as_deref(),
self.auth_route_config.as_ref(),
)
.await
.ok()
@@ -2149,6 +2204,7 @@ impl AuthManager {
forced_chatgpt_workspace_id: Option<Vec<String>>,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> Arc<Self> {
Arc::new(
Self::new(
@@ -2158,6 +2214,7 @@ impl AuthManager {
forced_chatgpt_workspace_id,
chatgpt_base_url,
keyring_backend_kind,
auth_route_config,
)
.await,
)
@@ -2175,6 +2232,7 @@ impl AuthManager {
config.forced_chatgpt_workspace_id(),
Some(config.chatgpt_base_url()),
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
.await
}
@@ -2328,7 +2386,9 @@ impl AuthManager {
let auth_dot_json = self
.auth_cached()
.and_then(|auth| auth.get_current_auth_json());
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await {
if let Err(err) =
revoke_auth_tokens(auth_dot_json.as_ref(), self.auth_route_config.as_ref()).await
{
tracing::warn!("failed to revoke auth tokens during logout: {err}");
}
let result = logout_all_stores(
@@ -5,7 +5,8 @@ use serde::Deserialize;
use std::env;
use std::fmt;
use crate::default_client::create_client;
use crate::default_client::create_default_auth_client;
use crate::outbound_proxy::AuthRouteConfig;
const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts";
const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL";
@@ -13,7 +14,7 @@ const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami";
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
struct PersonalAccessTokenMetadata {
email: String,
email: Option<String>,
chatgpt_user_id: String,
chatgpt_account_id: String,
chatgpt_plan_type: String,
@@ -36,13 +37,18 @@ impl fmt::Debug for PersonalAccessTokenAuth {
}
impl PersonalAccessTokenAuth {
pub(super) async fn load(access_token: &str) -> std::io::Result<Self> {
pub(super) async fn load(
access_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR)
.ok()
.map(|base_url| base_url.trim().trim_end_matches('/').to_string())
.filter(|base_url| !base_url.is_empty())
.unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string());
hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await
let endpoint = whoami_endpoint(&authapi_base_url);
let client = create_default_auth_client(&endpoint, auth_route_config)?;
hydrate_personal_access_token(&client, &endpoint, access_token).await
}
pub fn access_token(&self) -> &str {
@@ -57,8 +63,8 @@ impl PersonalAccessTokenAuth {
&self.metadata.chatgpt_user_id
}
pub fn email(&self) -> &str {
&self.metadata.email
pub fn email(&self) -> Option<&str> {
self.metadata.email.as_deref()
}
pub fn plan_type(&self) -> AccountPlanType {
@@ -72,12 +78,11 @@ impl PersonalAccessTokenAuth {
async fn hydrate_personal_access_token(
client: &CodexHttpClient,
authapi_base_url: &str,
endpoint: &str,
access_token: &str,
) -> std::io::Result<PersonalAccessTokenAuth> {
let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/'));
let response = client
.get(&endpoint)
.get(endpoint)
.bearer_auth(access_token)
.send()
.await
@@ -107,6 +112,10 @@ async fn hydrate_personal_access_token(
})
}
fn whoami_endpoint(authapi_base_url: &str) -> String {
format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/'))
}
#[cfg(test)]
#[path = "personal_access_token_tests.rs"]
mod tests;
@@ -1,4 +1,5 @@
use super::*;
use crate::default_client::create_client;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
@@ -29,7 +30,8 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
.mount(&server)
.await;
let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
let endpoint = whoami_endpoint(&server.uri());
let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
.await
.expect("personal access token hydration should succeed");
@@ -38,7 +40,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
PersonalAccessTokenAuth {
access_token: "at-example".to_string(),
metadata: PersonalAccessTokenMetadata {
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
chatgpt_user_id: "user-123".to_string(),
chatgpt_account_id: "account-123".to_string(),
chatgpt_plan_type: "enterprise".to_string(),
@@ -50,7 +52,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
}
#[tokio::test]
async fn hydrate_rejects_missing_email() {
async fn hydrate_preserves_missing_email() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(WHOAMI_PATH))
@@ -59,13 +61,23 @@ async fn hydrate_rejects_missing_email() {
.mount(&server)
.await;
let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
let endpoint = whoami_endpoint(&server.uri());
let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
.await
.expect_err("personal access token hydration should reject missing email");
.expect("personal access token hydration should accept missing email");
assert!(
err.to_string()
.contains("failed to decode personal access token metadata")
assert_eq!(
auth,
PersonalAccessTokenAuth {
access_token: "at-example".to_string(),
metadata: PersonalAccessTokenMetadata {
email: None,
chatgpt_user_id: "user-123".to_string(),
chatgpt_account_id: "account-123".to_string(),
chatgpt_plan_type: "enterprise".to_string(),
chatgpt_account_is_fedramp: true,
},
}
);
server.verify().await;
}
+4 -2
View File
@@ -16,7 +16,8 @@ use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR;
use super::manager::oauth_client_id;
use super::storage::AuthDotJson;
use super::util::try_parse_error_message;
use crate::default_client::create_client;
use crate::default_client::create_default_auth_client;
use crate::outbound_proxy::AuthRouteConfig;
use crate::token_data::TokenData;
const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
@@ -53,13 +54,14 @@ struct RevokeTokenRequest<'a> {
pub(super) async fn revoke_auth_tokens(
auth_dot_json: Option<&AuthDotJson>,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<(), std::io::Error> {
let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else {
return Ok(());
};
let client = create_client();
let endpoint = revoke_token_endpoint();
let client = create_default_auth_client(&endpoint, auth_route_config)?;
revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await
}
+25 -1
View File
@@ -92,13 +92,37 @@ pub struct AgentIdentityAuthRecord {
pub agent_private_key: String,
pub account_id: String,
pub chatgpt_user_id: String,
pub email: String,
#[serde(
default,
deserialize_with = "deserialize_optional_non_empty_string",
serialize_with = "serialize_optional_string_as_empty"
)]
pub email: Option<String>,
pub plan_type: AccountPlanType,
pub chatgpt_account_is_fedramp: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
}
fn deserialize_optional_non_empty_string<'de, D>(
deserializer: D,
) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
}
fn serialize_optional_string_as_empty<S>(
value: &Option<String>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
value.as_deref().unwrap_or_default().serialize(serializer)
}
impl AgentIdentityAuthRecord {
pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result<Self> {
let claims =
+80 -1
View File
@@ -102,7 +102,7 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re
agent_private_key: "private-key".to_string(),
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: AccountPlanType::Pro,
chatgpt_account_is_fedramp: false,
task_id: Some("task-id".to_string()),
@@ -124,6 +124,85 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re
Ok(())
}
#[tokio::test]
async fn file_storage_loads_empty_agent_identity_email_as_none() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_file = get_auth_file(codex_home.path());
std::fs::write(
&auth_file,
serde_json::to_string_pretty(&json!({
"auth_mode": "chatgpt",
"agent_identity": {
"agent_runtime_id": "agent-runtime-id",
"agent_private_key": "private-key",
"account_id": "account-id",
"chatgpt_user_id": "user-id",
"email": "",
"plan_type": "pro",
"chatgpt_account_is_fedramp": false,
},
}))?,
)?;
let loaded = storage.load()?;
assert_eq!(
loaded,
Some(AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord {
agent_runtime_id: "agent-runtime-id".to_string(),
agent_private_key: "private-key".to_string(),
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: None,
plan_type: AccountPlanType::Pro,
chatgpt_account_is_fedramp: false,
task_id: None,
})),
personal_access_token: None,
bedrock_api_key: None,
})
);
Ok(())
}
#[tokio::test]
async fn file_storage_writes_missing_agent_identity_email_as_empty_string() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord {
agent_runtime_id: "agent-runtime-id".to_string(),
agent_private_key: "private-key".to_string(),
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: None,
plan_type: AccountPlanType::Pro,
chatgpt_account_is_fedramp: false,
task_id: None,
})),
personal_access_token: None,
bedrock_api_key: None,
};
storage.save(&auth_dot_json)?;
let auth_file = get_auth_file(codex_home.path());
let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(auth_file)?)?;
assert_eq!(saved["agent_identity"]["email"], "");
assert_eq!(storage.load()?, Some(auth_dot_json));
Ok(())
}
#[tokio::test]
async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
+6 -3
View File
@@ -6,9 +6,9 @@ use serde::de::{self};
use std::time::Duration;
use std::time::Instant;
use crate::default_client::build_raw_auth_reqwest_client;
use crate::pkce::PkceCodes;
use crate::server::ServerOptions;
use codex_client::build_reqwest_client_with_custom_ca;
use std::io;
const ANSI_BLUE: &str = "\x1b[94m";
@@ -157,8 +157,10 @@ fn print_device_code_prompt(verification_url: &str, code: &str) {
}
pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result<DeviceCode> {
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let base_url = opts.issuer.trim_end_matches('/');
// The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint
// paths are not resolved separately.
let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?;
let api_base_url = format!("{base_url}/api/accounts");
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
@@ -174,8 +176,8 @@ pub async fn complete_device_code_login(
opts: ServerOptions,
device_code: DeviceCode,
) -> std::io::Result<()> {
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let base_url = opts.issuer.trim_end_matches('/');
let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?;
let api_base_url = format!("{base_url}/api/accounts");
let code_resp = poll_for_token(
@@ -199,6 +201,7 @@ pub async fn complete_device_code_login(
&redirect_uri,
&pkce,
&code_resp.authorization_code,
opts.auth_route_config.as_ref(),
)
.await
.map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?;
+2
View File
@@ -3,6 +3,7 @@ pub mod auth_env_telemetry;
pub mod token_data;
mod device_code_auth;
mod outbound_proxy;
mod pkce;
mod server;
@@ -53,4 +54,5 @@ pub use auth::read_openai_api_key_from_env;
pub use auth::save_auth;
pub use auth_env_telemetry::AuthEnvTelemetry;
pub use auth_env_telemetry::collect_auth_env_telemetry;
pub use outbound_proxy::AuthRouteConfig;
pub use token_data::TokenData;
+22
View File
@@ -0,0 +1,22 @@
use codex_client::OutboundProxyConfig;
/// Auth-layer adapter around client-owned proxy policy.
///
/// `AuthConfig` carries this value while endpoint resolution and platform details remain in the
/// client layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthRouteConfig {
route_config: OutboundProxyConfig,
}
impl AuthRouteConfig {
pub fn respect_system_proxy() -> Self {
Self {
route_config: OutboundProxyConfig::respect_system_proxy(),
}
}
pub(crate) fn route_config(&self) -> &OutboundProxyConfig {
&self.route_config
}
}
+28 -8
View File
@@ -27,7 +27,9 @@ use std::time::Duration;
use crate::auth::AuthDotJson;
use crate::auth::AuthKeyringBackendKind;
use crate::auth::save_auth;
use crate::default_client::build_raw_auth_reqwest_client;
use crate::default_client::originator;
use crate::outbound_proxy::AuthRouteConfig;
use crate::pkce::PkceCodes;
use crate::pkce::generate_pkce;
use crate::token_data::TokenData;
@@ -35,7 +37,6 @@ use crate::token_data::parse_chatgpt_jwt_claims;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_template::Template;
use rand::RngCore;
@@ -71,6 +72,7 @@ pub struct ServerOptions {
pub codex_streamlined_login: bool,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub auth_keyring_backend_kind: AuthKeyringBackendKind,
pub auth_route_config: Option<AuthRouteConfig>,
}
impl ServerOptions {
@@ -81,6 +83,7 @@ impl ServerOptions {
forced_chatgpt_workspace_id: Option<Vec<String>>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
auth_keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> Self {
Self {
codex_home,
@@ -93,6 +96,7 @@ impl ServerOptions {
codex_streamlined_login: false,
cli_auth_credentials_store_mode,
auth_keyring_backend_kind,
auth_route_config,
}
}
}
@@ -336,8 +340,15 @@ async fn process_request(
}
};
match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code)
.await
match exchange_code_for_tokens(
&opts.issuer,
&opts.client_id,
redirect_uri,
pkce,
&code,
opts.auth_route_config.as_ref(),
)
.await
{
Ok(tokens) => {
if let Err(message) = ensure_workspace_allowed(
@@ -353,9 +364,14 @@ async fn process_request(
);
}
// Obtain API key via token-exchange and persist
let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token)
.await
.ok();
let api_key = obtain_api_key(
&opts.issuer,
&opts.client_id,
&tokens.id_token,
opts.auth_route_config.as_ref(),
)
.await
.ok();
if let Err(err) = persist_tokens_async(
&opts.codex_home,
api_key.clone(),
@@ -719,6 +735,7 @@ pub(crate) async fn exchange_code_for_tokens(
redirect_uri: &str,
pkce: &PkceCodes,
code: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> io::Result<ExchangedTokens> {
#[derive(serde::Deserialize)]
struct TokenResponse {
@@ -727,7 +744,9 @@ pub(crate) async fn exchange_code_for_tokens(
refresh_token: String,
}
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
// The route selected for the issuer is reused for token exchange; the token endpoint path is
// not resolved separately.
let client = build_raw_auth_reqwest_client(issuer.trim_end_matches('/'), auth_route_config)?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
info!(
issuer = %sanitize_url_for_logging(issuer),
@@ -1129,14 +1148,15 @@ pub(crate) async fn obtain_api_key(
issuer: &str,
client_id: &str,
id_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> io::Result<String> {
// Token exchange for an API key access token
#[derive(serde::Deserialize)]
struct ExchangeResp {
access_token: String,
}
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
let client = build_raw_auth_reqwest_client(&token_endpoint, auth_route_config)?;
let resp = client
.post(token_endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
@@ -1220,6 +1220,7 @@ impl RefreshTokenTestContext {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -112,6 +112,7 @@ fn server_opts(
/*forced_chatgpt_workspace_id*/ None,
cli_auth_credentials_store_mode,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -277,6 +278,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail
/*forced_chatgpt_workspace_id*/ None,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -332,6 +334,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result
/*forced_chatgpt_workspace_id*/ None,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -122,6 +122,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
let opts = ServerOptions {
codex_home: server_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -185,6 +186,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
let opts = ServerOptions {
codex_home: server_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -226,6 +228,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<
let opts = ServerOptions {
codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -268,6 +271,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
let opts = ServerOptions {
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -329,6 +333,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error()
let opts = ServerOptions {
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -398,6 +403,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<
let opts = ServerOptions {
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -507,6 +513,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() ->
/*forced_chatgpt_workspace_id*/ None,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -545,6 +552,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
let first_opts = ServerOptions {
codex_home: first_codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer: issuer.clone(),
port: 0,
@@ -567,6 +575,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
let second_opts = ServerOptions {
codex_home: second_codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: login_port,
+4
View File
@@ -61,6 +61,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result<
codex_home.path(),
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await?;
@@ -119,6 +120,7 @@ async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> R
codex_home.path(),
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await?;
@@ -161,6 +163,7 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> {
codex_home.path(),
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await?;
@@ -204,6 +207,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
save_auth(
+13 -12
View File
@@ -60,10 +60,7 @@ impl fmt::Display for ProviderAccountError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingChatgptAccountDetails => {
write!(
f,
"email and plan type are required for chatgpt authentication"
)
write!(f, "plan type is required for chatgpt authentication")
}
Self::UnsupportedBedrockApiKeyAuth => {
write!(
@@ -261,12 +258,9 @@ impl ModelProvider for ConfiguredModelProvider {
let email = auth.get_account_email();
let plan_type = auth.account_plan_type();
match (email, plan_type) {
(Some(email), Some(plan_type)) => {
Ok(ProviderAccount::Chatgpt { email, plan_type })
}
_ => Err(ProviderAccountError::MissingChatgptAccountDetails),
}
plan_type
.map(|plan_type| ProviderAccount::Chatgpt { email, plan_type })
.ok_or(ProviderAccountError::MissingChatgptAccountDetails)
}
})
.transpose()?
@@ -313,6 +307,7 @@ mod tests {
use codex_model_provider_info::ModelProviderAwsAuthInfo;
use codex_model_provider_info::WireApi;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
@@ -518,7 +513,7 @@ mod tests {
}
#[test]
fn openai_provider_rejects_chatgpt_account_state_without_email() {
fn openai_provider_returns_chatgpt_account_state_without_email() {
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(AuthManager::from_auth_for_testing(
@@ -528,7 +523,13 @@ mod tests {
assert_eq!(
provider.account_state(),
Err(ProviderAccountError::MissingChatgptAccountDetails)
Ok(ProviderAccountState {
account: Some(ProviderAccount::Chatgpt {
email: None,
plan_type: PlanType::Unknown,
}),
requires_openai_auth: true,
})
);
}
@@ -232,6 +232,7 @@ c2ln",
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("auth should load")
+6
View File
@@ -44,3 +44,9 @@ tempfile = { workspace = true }
[target.'cfg(target_family = "unix")'.dependencies]
rama-unix = { version = "=0.3.0-alpha.4" }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
[target.'cfg(windows)'.dependencies]
schannel = "0.1"
+258 -19
View File
@@ -23,6 +23,7 @@ use rama_tls_rustls::server::TlsAcceptorData;
use sha2::Digest as _;
use sha2::Sha256;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
@@ -30,6 +31,7 @@ use std::io::Write;
use std::net::IpAddr;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use tracing::info;
@@ -101,6 +103,7 @@ const MANAGED_MITM_CA_DIR: &str = "proxy";
const MANAGED_MITM_CA_CERT: &str = "ca.pem";
const MANAGED_MITM_CA_KEY: &str = "ca.key";
const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle";
pub(crate) const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR";
// Best-effort compatibility set for common child toolchains that accept a CA bundle path.
// This is intentionally curated rather than pretending to cover every TLS client.
@@ -117,6 +120,14 @@ pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [
"NPM_CONFIG_CAFILE",
];
pub(crate) fn ca_env_from_process() -> HashMap<&'static str, String> {
CUSTOM_CA_ENV_KEYS
.into_iter()
.chain([SSL_CERT_DIR_ENV_KEY])
.filter_map(|key| std::env::var(key).ok().map(|value| (key, value)))
.collect()
}
/// Immutable managed MITM CA bundle path plus startup TLS env values.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManagedMitmCaTrustBundle {
@@ -146,15 +157,13 @@ fn managed_ca_trust_bundle_for_cert_path(
cert_path: &Path,
env: &HashMap<&'static str, String>,
) -> Result<ManagedMitmCaTrustBundle> {
let startup_env_values = CUSTOM_CA_ENV_KEYS
.into_iter()
.filter_map(|key| {
env.get(key)
.filter(|value| !value.is_empty())
.map(|value| (key, value.clone()))
})
.collect();
let trust_bundle = build_managed_ca_trust_bundle(cert_path)?;
let startup_env_values = startup_ca_file_env_values(env);
let startup_cert_dir = env
.get(SSL_CERT_DIR_ENV_KEY)
.filter(|value| !value.is_empty())
.map(String::as_str);
let trust_bundle =
build_managed_ca_trust_bundle(cert_path, &startup_env_values, startup_cert_dir)?;
let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?;
Ok(ManagedMitmCaTrustBundle {
@@ -163,21 +172,188 @@ fn managed_ca_trust_bundle_for_cert_path(
})
}
fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result<String> {
pub(crate) fn upstream_tls_root_store(
env: &HashMap<&'static str, String>,
) -> Result<Arc<rustls::RootCertStore>> {
let (managed_ca_cert_path, _) = managed_ca_paths()?;
upstream_tls_root_store_for_cert_path(&managed_ca_cert_path, env)
}
pub(crate) fn upstream_tls_root_store_for_cert_path(
managed_ca_cert_path: &Path,
env: &HashMap<&'static str, String>,
) -> Result<Arc<rustls::RootCertStore>> {
let startup_env_values = startup_ca_file_env_values(env);
let startup_cert_dir = env
.get(SSL_CERT_DIR_ENV_KEY)
.filter(|value| !value.is_empty())
.map(String::as_str);
let certificates = load_platform_and_startup_root_certificates(
managed_ca_cert_path,
&startup_env_values,
startup_cert_dir,
)?;
let mut roots = rustls::RootCertStore::empty();
let (_, ignored) = roots.add_parsable_certificates(certificates);
if ignored > 0 {
warn!(
ignored_root_count = ignored,
"ignored invalid platform or startup roots for MITM upstream TLS"
);
}
Ok(Arc::new(roots))
}
fn startup_ca_file_env_values(
env: &HashMap<&'static str, String>,
) -> HashMap<&'static str, String> {
CUSTOM_CA_ENV_KEYS
.into_iter()
.filter_map(|key| {
env.get(key)
.filter(|value| !value.is_empty())
.map(|value| (key, value.clone()))
})
.collect()
}
fn build_managed_ca_trust_bundle(
managed_ca_cert_path: &Path,
startup_env_values: &HashMap<&'static str, String>,
startup_cert_dir: Option<&str>,
) -> Result<String> {
let mut trust_bundle = String::new();
for cert in load_platform_and_startup_root_certificates(
managed_ca_cert_path,
startup_env_values,
startup_cert_dir,
)? {
push_certificate_pem(&mut trust_bundle, cert.as_ref());
}
append_pem_file(&mut trust_bundle, managed_ca_cert_path)?;
Ok(trust_bundle)
}
fn load_platform_and_startup_root_certificates(
managed_ca_cert_path: &Path,
startup_env_values: &HashMap<&'static str, String>,
startup_cert_dir: Option<&str>,
) -> Result<Vec<CertificateDer<'static>>> {
let managed_ca_cert = fs::read(managed_ca_cert_path).with_context(|| {
format!(
"failed to read managed MITM CA certificate: {}",
managed_ca_cert_path.display()
)
})?;
let managed_ca_cert = CertificateDer::from_pem_slice(&managed_ca_cert)
.context("failed to parse managed MITM CA certificate")?;
let rustls_native_certs::CertificateResult { certs, errors, .. } =
rustls_native_certs::load_native_certs();
crate::native_certs::load_platform_native_certs();
if !errors.is_empty() {
warn!(
native_root_error_count = errors.len(),
"encountered errors while loading native root certificates for MITM trust bundle"
);
}
for cert in certs {
push_certificate_pem(&mut trust_bundle, cert.as_ref());
let mut certificates = certs;
let mut appended_startup_paths = HashSet::new();
for path in CUSTOM_CA_ENV_KEYS
.into_iter()
.filter_map(|key| startup_env_values.get(key))
.map(PathBuf::from)
{
if path != managed_ca_cert_path
&& !is_current_generated_trust_bundle_path(&path, managed_ca_cert_path)
&& appended_startup_paths.insert(path.clone())
{
certificates.extend(read_ca_certificates(&path)?);
}
}
append_pem_file(&mut trust_bundle, managed_ca_cert_path)?;
Ok(trust_bundle)
if let Some(startup_cert_dir) = startup_cert_dir {
for path in std::env::split_paths(startup_cert_dir) {
if appended_startup_paths.insert(path.clone()) {
certificates.extend(load_ca_directory_certificates(&path));
}
}
}
let mut seen = HashSet::new();
certificates.retain(|cert| cert != &managed_ca_cert && seen.insert(cert.as_ref().to_vec()));
Ok(certificates)
}
fn read_ca_certificates(path: &Path) -> Result<Vec<CertificateDer<'static>>> {
let pem = fs::read(path)
.with_context(|| format!("failed to read startup CA bundle: {}", path.display()))?;
let pem = String::from_utf8_lossy(&pem);
let contains_trusted_certificates = pem.contains("TRUSTED CERTIFICATE");
let normalized_pem = pem
.replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE")
.replace("END TRUSTED CERTIFICATE", "END CERTIFICATE");
let certs = CertificateDer::pem_slice_iter(normalized_pem.as_bytes())
.collect::<std::result::Result<Vec<_>, _>>()
.with_context(|| format!("failed to parse startup CA bundle: {}", path.display()))?;
if certs.is_empty() {
return Err(anyhow!(
"startup CA bundle contained no certificates: {}",
path.display()
));
}
certs
.into_iter()
.map(|cert| {
let cert = if contains_trusted_certificates {
first_der_item(cert.as_ref()).ok_or_else(|| {
anyhow!(
"startup CA bundle contained an invalid trusted certificate: {}",
path.display()
)
})?
} else {
cert.as_ref()
};
Ok(CertificateDer::from(cert.to_vec()))
})
.collect()
}
fn load_ca_directory_certificates(path: &Path) -> Vec<CertificateDer<'static>> {
let rustls_native_certs::CertificateResult { certs, errors, .. } =
rustls_native_certs::load_certs_from_paths(None, Some(path));
if !errors.is_empty() {
warn!(
ca_path = %path.display(),
ca_error_count = errors.len(),
"encountered errors while loading startup CA directory"
);
}
certs
}
fn first_der_item(der: &[u8]) -> Option<&[u8]> {
der_item_length(der).map(|length| &der[..length])
}
fn der_item_length(der: &[u8]) -> Option<usize> {
let &length_octet = der.get(1)?;
if length_octet & 0x80 == 0 {
return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len());
}
let length_octets = usize::from(length_octet & 0x7f);
if length_octets == 0 {
return None;
}
let length_end = 2usize.checked_add(length_octets)?;
let mut content_length = 0usize;
for &byte in der.get(2..length_end)? {
content_length = content_length
.checked_mul(256)?
.checked_add(usize::from(byte))?;
}
length_end
.checked_add(content_length)
.filter(|length| *length <= der.len())
}
fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Path) -> bool {
@@ -508,17 +684,80 @@ mod tests {
}
#[test]
fn managed_ca_trust_bundle_records_startup_ca_env_values() {
fn managed_ca_trust_bundle_appends_startup_file_and_directory_certificates() {
let dir = tempdir().unwrap();
let managed_ca_cert_path = dir.path().join("ca.pem");
fs::write(&managed_ca_cert_path, "managed ca\n").unwrap();
let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]);
let startup_ca_bundle_path = dir.path().join("startup-ca.pem");
let startup_ca_dir = dir.path().join("startup-certs");
let (managed_ca_cert, _) = generate_ca().unwrap();
let (startup_ca_cert, startup_ca_key) = generate_ca().unwrap();
let (directory_ca_cert, _) = generate_ca().unwrap();
let mut trusted_ca_der = CertificateDer::from_pem_slice(startup_ca_cert.as_bytes())
.unwrap()
.as_ref()
.to_vec();
trusted_ca_der.extend_from_slice(&[0x30, 0x00]);
let mut trusted_ca_cert = String::new();
push_certificate_pem(&mut trusted_ca_cert, &trusted_ca_der);
let trusted_ca_cert = trusted_ca_cert.replace("CERTIFICATE", "TRUSTED CERTIFICATE");
fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap();
fs::write(
&startup_ca_bundle_path,
format!("{trusted_ca_cert}{startup_ca_key}"),
)
.unwrap();
fs::create_dir(&startup_ca_dir).unwrap();
fs::write(startup_ca_dir.join("directory-ca.pem"), &directory_ca_cert).unwrap();
let startup_ca_bundle_path = startup_ca_bundle_path.display().to_string();
let env = HashMap::from([
("SSL_CERT_FILE", startup_ca_bundle_path.clone()),
(SSL_CERT_DIR_ENV_KEY, startup_ca_dir.display().to_string()),
]);
let trust_bundle =
managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap();
assert_eq!(
trust_bundle.startup_env_values,
HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())])
HashMap::from([("SSL_CERT_FILE", startup_ca_bundle_path)])
);
let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap();
let baseline_certs = CertificateDer::pem_slice_iter(baseline_bundle.as_bytes())
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
let expected_certs = [&startup_ca_cert, &directory_ca_cert, &managed_ca_cert]
.map(|cert| CertificateDer::from_pem_slice(cert.as_bytes()).unwrap());
assert!(
expected_certs
.iter()
.all(|cert| baseline_certs.contains(cert))
);
assert!(!baseline_bundle.contains(&startup_ca_key));
assert!(!baseline_bundle.contains("TRUSTED CERTIFICATE"));
}
#[test]
fn managed_ca_trust_bundle_skips_inherited_current_bundle() {
let dir = tempdir().unwrap();
let managed_ca_cert_path = dir.path().join("ca.pem");
let inherited_bundle_path = dir.path().join("ca-bundle-parent.pem");
let (managed_ca_cert, _) = generate_ca().unwrap();
fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap();
fs::write(
&inherited_bundle_path,
format!("parent roots\n{managed_ca_cert}"),
)
.unwrap();
let env = HashMap::from([(
"REQUESTS_CA_BUNDLE",
inherited_bundle_path.display().to_string(),
)]);
let trust_bundle =
managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap();
let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap();
assert_eq!(baseline_bundle.matches(&managed_ca_cert).count(), 1);
}
#[cfg(unix)]
+1
View File
@@ -6,6 +6,7 @@ mod connect_policy;
mod http_proxy;
mod mitm;
mod mitm_hook;
mod native_certs;
mod network_policy;
mod policy;
mod proxy;
+10 -2
View File
@@ -107,11 +107,19 @@ impl MitmState {
// generate/load a local CA and issue per-host leaf certs so we can terminate TLS and
// apply policy.
let ca = ManagedMitmCa::load_or_create()?;
let upstream_tls_root_store =
crate::certs::upstream_tls_root_store(&crate::certs::ca_env_from_process())?;
let upstream = if config.allow_upstream_proxy {
UpstreamClient::from_env_proxy_with_allow_local_binding(config.allow_local_binding)
UpstreamClient::from_env_proxy_with_allow_local_binding(
config.allow_local_binding,
upstream_tls_root_store,
)
} else {
UpstreamClient::direct_with_allow_local_binding(config.allow_local_binding)
UpstreamClient::direct_with_allow_local_binding(
config.allow_local_binding,
upstream_tls_root_store,
)
};
Ok(Self {
+260
View File
@@ -0,0 +1,260 @@
#[cfg(any(target_os = "macos", windows))]
use rama_tls_rustls::dep::pki_types::CertificateDer;
use rustls_native_certs::CertificateResult;
#[cfg(any(target_os = "macos", windows))]
use rustls_native_certs::Error;
#[cfg(any(target_os = "macos", windows))]
use rustls_native_certs::ErrorKind;
// `rustls_native_certs::load_native_certs()` first consults SSL_CERT_FILE and
// SSL_CERT_DIR. Load platform roots directly so a startup custom CA can be
// layered onto the managed bundle without replacing the platform trust store.
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn load_platform_native_certs() -> CertificateResult {
let mut result =
rustls_native_certs::load_certs_from_paths(platform_cert_file().as_deref(), None);
for cert_dir in platform_cert_dirs() {
extend_certificate_result(
&mut result,
rustls_native_certs::load_certs_from_paths(None, Some(&cert_dir)),
);
}
dedupe_certs(&mut result);
result
}
#[cfg(target_os = "macos")]
pub(crate) fn load_platform_native_certs() -> CertificateResult {
use security_framework::trust_settings::Domain;
use security_framework::trust_settings::TrustSettings;
use security_framework::trust_settings::TrustSettingsForCertificate;
use std::collections::BTreeMap;
let mut result = CertificateResult::default();
let mut all_certs = BTreeMap::new();
for domain in &[Domain::User, Domain::Admin, Domain::System] {
let ts = TrustSettings::new(*domain);
let iter = match ts.iter() {
Ok(iter) => iter,
Err(err) => {
result.errors.push(Error {
context: match domain {
Domain::User => "failed to load user trust settings",
Domain::Admin => "failed to load admin trust settings",
Domain::System => "failed to load system trust settings",
},
kind: ErrorKind::Os(err.into()),
});
continue;
}
};
for cert in iter {
let der = cert.to_der();
let trusted = match ts.tls_trust_settings_for_certificate(&cert) {
Ok(trusted) => trusted.unwrap_or(TrustSettingsForCertificate::TrustRoot),
Err(err) => {
result.errors.push(Error {
context: "certificate not trusted",
kind: ErrorKind::Os(err.into()),
});
continue;
}
};
all_certs.entry(der).or_insert(trusted);
}
}
for (der, trusted) in all_certs {
use TrustSettingsForCertificate::*;
if let TrustRoot | TrustAsRoot = trusted {
result.certs.push(CertificateDer::from(der));
}
}
result
}
#[cfg(windows)]
pub(crate) fn load_platform_native_certs() -> CertificateResult {
use schannel::cert_store::CertStore;
let mut result = CertificateResult::default();
let current_user_store = match CertStore::open_current_user("ROOT") {
Ok(store) => store,
Err(err) => {
result.errors.push(Error {
context: "failed to open current user certificate store",
kind: ErrorKind::Os(err.into()),
});
return result;
}
};
for cert in current_user_store.certs() {
let valid_uses = match cert.valid_uses() {
Ok(valid_uses) => valid_uses,
Err(err) => {
result.errors.push(Error {
context: "failed to inspect certificate valid uses",
kind: ErrorKind::Os(err.into()),
});
continue;
}
};
let is_time_valid = match cert.is_time_valid() {
Ok(is_time_valid) => is_time_valid,
Err(err) => {
result.errors.push(Error {
context: "failed to inspect certificate time validity",
kind: ErrorKind::Os(err.into()),
});
continue;
}
};
if usable_for_rustls(valid_uses) && is_time_valid {
result
.certs
.push(CertificateDer::from(cert.to_der().to_vec()));
}
}
result
}
#[cfg(not(any(all(unix, not(target_os = "macos")), target_os = "macos", windows)))]
pub(crate) fn load_platform_native_certs() -> CertificateResult {
rustls_native_certs::load_native_certs()
}
#[cfg(all(unix, not(target_os = "macos")))]
fn extend_certificate_result(result: &mut CertificateResult, extra: CertificateResult) {
result.certs.extend(extra.certs);
result.errors.extend(extra.errors);
}
#[cfg(all(unix, not(target_os = "macos")))]
fn dedupe_certs(result: &mut CertificateResult) {
result.certs.sort_unstable_by(|a, b| a.cmp(b));
result.certs.dedup();
}
#[cfg(all(unix, not(target_os = "macos")))]
fn platform_cert_file() -> Option<std::path::PathBuf> {
PLATFORM_CERTIFICATE_FILE_NAMES
.iter()
.map(std::path::Path::new)
.find(|path| path.exists())
.map(std::path::Path::to_path_buf)
}
#[cfg(all(unix, not(target_os = "macos")))]
fn platform_cert_dirs() -> impl Iterator<Item = std::path::PathBuf> {
PLATFORM_CERTIFICATE_DIRS
.iter()
.map(std::path::Path::new)
.filter(|path| path.exists())
.map(std::path::Path::to_path_buf)
}
#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))]
const PLATFORM_CERTIFICATE_DIRS: &[&str] = &[
"/etc/ssl/certs",
"/etc/pki/tls/certs",
"/etc/security/certificates",
];
#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))]
const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs", "/usr/local/share/certs"];
#[cfg(all(
unix,
not(target_os = "macos"),
any(target_os = "illumos", target_os = "solaris")
))]
const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/certs/CA"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))]
const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/openssl/certs"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "aix"))]
const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/var/ssl/certs"];
#[cfg(all(
unix,
not(target_os = "macos"),
not(any(
target_os = "linux",
target_os = "freebsd",
target_os = "illumos",
target_os = "solaris",
target_os = "netbsd",
target_os = "aix"
))
))]
const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &[
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem",
"/etc/pki/tls/cacert.pem",
"/etc/ssl/cert.pem",
"/opt/etc/ssl/certs/ca-certificates.crt",
"/etc/ssl/certs/cacert.pem",
];
#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/etc/ssl/cert.pem"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "dragonfly"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/share/certs/ca-root-nss.crt"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/openssl/certs/ca-certificates.crt"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "openbsd"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/cert.pem"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "solaris"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/certs/ca-certificates.crt"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "illumos"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] =
&["/etc/ssl/cacert.pem", "/etc/certs/ca-certificates.crt"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "android"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] =
&["/data/data/com.termux/files/usr/etc/tls/cert.pem"];
#[cfg(all(unix, not(target_os = "macos"), target_os = "haiku"))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/boot/system/data/ssl/CARootCertificates.pem"];
#[cfg(all(
unix,
not(target_os = "macos"),
not(any(
target_os = "linux",
target_os = "freebsd",
target_os = "dragonfly",
target_os = "netbsd",
target_os = "openbsd",
target_os = "solaris",
target_os = "illumos",
target_os = "android",
target_os = "haiku",
))
))]
const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/certs/ca-certificates.crt"];
#[cfg(windows)]
fn usable_for_rustls(uses: schannel::cert_context::ValidUses) -> bool {
match uses {
schannel::cert_context::ValidUses::All => true,
schannel::cert_context::ValidUses::Oids(strs) => strs.iter().any(|x| x == PKIX_SERVER_AUTH),
}
}
#[cfg(windows)]
const PKIX_SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1";
+1 -4
View File
@@ -308,10 +308,7 @@ struct NetworkProxyRuntimeSettings {
impl NetworkProxyRuntimeSettings {
fn from_config(config: &config::NetworkProxyConfig) -> Result<Self> {
let mitm_ca_trust_bundle = if config.network.mitm {
let env = crate::certs::CUSTOM_CA_ENV_KEYS
.into_iter()
.filter_map(|key| std::env::var(key).ok().map(|value| (key, value)))
.collect();
let env = crate::certs::ca_env_from_process();
Some(crate::certs::managed_ca_trust_bundle(&env)?)
} else {
None
+29 -5
View File
@@ -21,6 +21,8 @@ use rama_net::client::EstablishedClientConnection;
use rama_net::http::RequestContext;
use rama_tls_rustls::client::TlsConnectorDataBuilder;
use rama_tls_rustls::client::TlsConnectorLayer;
use rama_tls_rustls::client::client_root_certs;
use rama_tls_rustls::dep::rustls;
use std::sync::Arc;
use std::time::Instant;
use tracing::info;
@@ -104,6 +106,7 @@ impl UpstreamClient {
Self::new(
ProxyConfig::default(),
TargetCheckedTcpConnector::new(state),
client_root_certs(),
)
}
@@ -111,20 +114,29 @@ impl UpstreamClient {
Self::new(
ProxyConfig::from_env(),
TargetCheckedTcpConnector::new(state),
client_root_certs(),
)
}
pub(crate) fn direct_with_allow_local_binding(allow_local_binding: bool) -> Self {
pub(crate) fn direct_with_allow_local_binding(
allow_local_binding: bool,
tls_root_store: Arc<rustls::RootCertStore>,
) -> Self {
Self::new(
ProxyConfig::default(),
TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding),
tls_root_store,
)
}
pub(crate) fn from_env_proxy_with_allow_local_binding(allow_local_binding: bool) -> Self {
pub(crate) fn from_env_proxy_with_allow_local_binding(
allow_local_binding: bool,
tls_root_store: Arc<rustls::RootCertStore>,
) -> Self {
Self::new(
ProxyConfig::from_env(),
TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding),
tls_root_store,
)
}
@@ -137,8 +149,12 @@ impl UpstreamClient {
}
}
fn new(proxy_config: ProxyConfig, transport: TargetCheckedTcpConnector) -> Self {
let connector = build_http_connector(transport);
fn new(
proxy_config: ProxyConfig,
transport: TargetCheckedTcpConnector,
tls_root_store: Arc<rustls::RootCertStore>,
) -> Self {
let connector = build_http_connector(transport, tls_root_store);
Self {
connector,
proxy_config,
@@ -221,6 +237,7 @@ impl Service<Request<Body>> for UpstreamClient {
fn build_http_connector(
transport: TargetCheckedTcpConnector,
tls_root_store: Arc<rustls::RootCertStore>,
) -> BoxService<
Request<Body>,
EstablishedClientConnection<HttpClientService<Body>, Request<Body>>,
@@ -228,7 +245,10 @@ fn build_http_connector(
> {
ensure_rustls_crypto_provider();
let proxy = HttpProxyConnectorLayer::optional().into_layer(transport);
let tls_config = TlsConnectorDataBuilder::new()
let client_config = rustls::ClientConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS)
.with_root_certificates(tls_root_store)
.with_no_client_auth();
let tls_config = TlsConnectorDataBuilder::from(client_config)
.with_alpn_protocols_http_auto()
.build();
let tls = TlsConnectorLayer::auto()
@@ -239,6 +259,10 @@ fn build_http_connector(
connector.boxed()
}
#[cfg(test)]
#[path = "upstream_tests.rs"]
mod tests;
#[cfg(target_os = "macos")]
fn build_unix_connector(
path: &str,
@@ -0,0 +1,111 @@
use super::*;
use pretty_assertions::assert_eq;
use rama_http::StatusCode;
use rama_tls_rustls::dep::pki_types::CertificateDer;
use rama_tls_rustls::dep::pki_types::PrivateKeyDer;
use rama_tls_rustls::dep::pki_types::pem::PemObject;
use rama_tls_rustls::dep::rcgen::BasicConstraints;
use rama_tls_rustls::dep::rcgen::CertificateParams;
use rama_tls_rustls::dep::rcgen::DistinguishedName;
use rama_tls_rustls::dep::rcgen::DnType;
use rama_tls_rustls::dep::rcgen::ExtendedKeyUsagePurpose;
use rama_tls_rustls::dep::rcgen::IsCa;
use rama_tls_rustls::dep::rcgen::Issuer;
use rama_tls_rustls::dep::rcgen::KeyPair;
use rama_tls_rustls::dep::rcgen::KeyUsagePurpose;
use rama_tls_rustls::dep::rcgen::PKCS_ECDSA_P256_SHA256;
use rama_tls_rustls::dep::tokio_rustls::TlsAcceptor;
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use tempfile::tempdir;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
fn generate_ca(common_name: &str) -> (String, KeyPair) {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let mut distinguished_name = DistinguishedName::new();
distinguished_name.push(DnType::CommonName, common_name);
params.distinguished_name = distinguished_name;
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let cert = params.self_signed(&key_pair).unwrap();
(cert.pem(), key_pair)
}
#[tokio::test]
async fn mitm_upstream_client_trusts_startup_custom_ca() {
ensure_rustls_crypto_provider();
let temp_dir = tempdir().unwrap();
let startup_ca_path = temp_dir.path().join("startup-ca.pem");
let managed_ca_path = temp_dir.path().join("managed-ca.pem");
let (startup_ca_pem, startup_ca_key) = generate_ca("startup CA");
let (managed_ca_pem, _) = generate_ca("managed MITM CA");
fs::write(&startup_ca_path, &startup_ca_pem).unwrap();
fs::write(&managed_ca_path, managed_ca_pem).unwrap();
let issuer = Issuer::from_ca_cert_pem(&startup_ca_pem, startup_ca_key).unwrap();
let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
server_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let server_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let server_cert = server_params.signed_by(&server_key, &issuer).unwrap();
let server_cert = CertificateDer::from_pem_slice(server_cert.pem().as_bytes()).unwrap();
let server_key = PrivateKeyDer::from_pem_slice(server_key.serialize_pem().as_bytes()).unwrap();
let mut server_config =
rustls::ServerConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS)
.with_no_client_auth()
.with_single_cert(vec![server_cert], server_key)
.unwrap();
server_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let env = HashMap::from([(
"SSL_CERT_FILE",
startup_ca_path.to_string_lossy().into_owned(),
)]);
let roots =
crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &env).unwrap();
let baseline_roots =
crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &HashMap::new())
.unwrap();
assert_eq!(roots.len(), baseline_roots.len() + 1);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let acceptor = TlsAcceptor::from(Arc::new(server_config));
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(stream).await.unwrap();
let mut request = [0; 4096];
let bytes_read = stream.read(&mut request).await.unwrap();
assert!(bytes_read > 0);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await
.unwrap();
});
let client =
UpstreamClient::direct_with_allow_local_binding(/*allow_local_binding*/ true, roots);
let response = client
.serve(
Request::builder()
.uri(format!("https://localhost:{}/", address.port()))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
server.await.unwrap();
}
+1 -1
View File
@@ -35,7 +35,7 @@ pub enum PlanType {
pub enum ProviderAccount {
ApiKey,
Chatgpt {
email: String,
email: Option<String>,
plan_type: PlanType,
},
AmazonBedrock {
+1
View File
@@ -148,6 +148,7 @@ arboard = { workspace = true }
[dev-dependencies]
app_test_support = { workspace = true }
codex-cli = { workspace = true }
codex-mcp = { workspace = true }
core_test_support = { workspace = true }
+6 -3
View File
@@ -311,16 +311,19 @@ impl AppServerSession {
false,
),
Some(Account::Chatgpt { email, plan_type }) => {
let feedback_audience = if email.ends_with("@openai.com") {
let feedback_audience = if email
.as_deref()
.is_some_and(|email| email.ends_with("@openai.com"))
{
FeedbackAudience::OpenAiEmployee
} else {
FeedbackAudience::External
};
(
Some(email.clone()),
email.clone(),
Some(TelemetryAuthMode::Chatgpt),
Some(StatusAccountDisplay::ChatGpt {
email: Some(email),
email,
plan: Some(plan_type_display_name(plan_type)),
}),
Some(plan_type),
@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
impl ChatWidget {
pub(super) fn open_permission_profiles_popup(&mut self) {
@@ -48,7 +49,7 @@ impl ChatWidget {
}
items.push(self.builtin_permission_mode_selection_item(
full_access,
":danger-no-sandbox",
BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS,
full_access.description.to_string(),
AskForApproval::from(full_access.approval),
ApprovalsReviewer::User,
@@ -73,6 +74,7 @@ impl ChatWidget {
.as_deref()
.unwrap_or("Configured permission profile."),
active_profile_id.as_deref(),
profile.allowed,
)
}),
);
@@ -141,6 +143,12 @@ impl ChatWidget {
.can_set_permission_profile(&preset.permission_profile)
.err()
.map(|err| err.to_string())
})
.or_else(|| {
(!self
.config
.is_permission_profile_allowed(id, &preset.permission_profile))
.then(|| "Disabled by requirements.".to_string())
}),
..Default::default()
}
@@ -151,6 +159,7 @@ impl ChatWidget {
id: &str,
description: &str,
active_profile_id: Option<&str>,
allowed: bool,
) -> SelectionItem {
let id_for_action = id.to_string();
let selection = PermissionProfileSelection {
@@ -165,6 +174,7 @@ impl ChatWidget {
is_current: active_profile_id == Some(id),
actions: Self::permission_profile_selection_actions(selection),
dismiss_on_select: true,
disabled_reason: (!allowed).then(|| "Disabled by requirements.".to_string()),
..Default::default()
}
}
@@ -4,18 +4,19 @@ expression: "render_bottom_popup(&chat, 80)"
---
Update Model Permissions
1. Ask for approval Codex can read and edit files in the current
workspace, and run commands. Approval is required
to access the internet or edit other files.
2. Approve for me Only ask for actions detected as potentially
unsafe.
3. Full Access Codex can edit files outside this workspace and
access the internet without asking for approval.
Exercise caution when using.
4. Read Only Codex can read files in the current workspace.
Approval is required to edit files or access the
internet.
› 5. locked-down (current) Inspect and patch only approved workspace files.
6. web-enabled Workspace profile with network access.
1. Ask for approval Codex can read and edit files in the current
workspace, and run commands. Approval is required
to access the internet or edit other files.
2. Approve for me Only ask for actions detected as potentially
unsafe.
3. Full Access Codex can edit files outside this workspace and
access the internet without asking for approval.
Exercise caution when using.
4. Read Only Codex can read files in the current workspace.
Approval is required to edit files or access the
internet.
› 5. locked-down (current) Inspect and patch only approved workspace files.
web-enabled (disabled) Workspace profile with network access. (disabled:
Disabled by requirements.)
Press enter to confirm or esc to go back

Some files were not shown because too many files have changed in this diff Show More