mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Merge 60ba7ddcb7 into sapling-pr-archive-bolinfest
This commit is contained in:
+1
-1
@@ -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)",
|
||||
|
||||
Generated
+4
-4
@@ -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\":{}}",
|
||||
|
||||
Generated
+20
-19
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+9
-1
@@ -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"
|
||||
|
||||
+9
-1
@@ -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"
|
||||
|
||||
+5
@@ -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, };
|
||||
|
||||
+5
-1
@@ -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(¶ms)?;
|
||||
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 ¶ms.questions {
|
||||
writeln!(output, "\n{}: {}", question.header, question.question)?;
|
||||
let options = question
|
||||
.options
|
||||
.as_deref()
|
||||
.filter(|options| !options.is_empty());
|
||||
let answer_values = if let Some(options) = options {
|
||||
for (index, option) in options.iter().enumerate() {
|
||||
writeln!(
|
||||
output,
|
||||
" {}. {} - {}",
|
||||
index + 1,
|
||||
option.label,
|
||||
option.description
|
||||
)?;
|
||||
}
|
||||
if question.is_other {
|
||||
writeln!(output, " o. Other (free-form)")?;
|
||||
}
|
||||
|
||||
loop {
|
||||
if question.is_other {
|
||||
write!(output, "Choose 1-{} or o: ", options.len())?;
|
||||
} else {
|
||||
write!(output, "Choose 1-{}: ", options.len())?;
|
||||
}
|
||||
output.flush()?;
|
||||
|
||||
let mut line = String::new();
|
||||
if input
|
||||
.read_line(&mut line)
|
||||
.context("failed to read request_user_input selection")?
|
||||
== 0
|
||||
{
|
||||
bail!("stdin closed while waiting for request_user_input selection");
|
||||
}
|
||||
let selection = line.trim();
|
||||
|
||||
if let Ok(index) = selection.parse::<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, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
ToolRequestUserInputResponse {
|
||||
answers: HashMap::from([
|
||||
(
|
||||
"target".to_string(),
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["TUI".to_string()],
|
||||
},
|
||||
),
|
||||
(
|
||||
"details".to_string(),
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["user_note: include snapshots".to_string()],
|
||||
},
|
||||
),
|
||||
]),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
concat!(
|
||||
"\n[request_user_input for thread thread-1, turn turn-1]\n",
|
||||
"The app-server may auto-resolve this request after 60000 ms.\n",
|
||||
"\nTarget: Which target?\n",
|
||||
" 1. Core - Inspect core\n",
|
||||
" 2. TUI - Inspect TUI\n",
|
||||
" o. Other (free-form)\n",
|
||||
"Choose 1-2 or o: ",
|
||||
"\nDetails: Anything else?\n",
|
||||
"Answer: ",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_invalid_selection_and_collects_other_answer() {
|
||||
let params = ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "item-1".to_string(),
|
||||
questions: vec![ToolRequestUserInputQuestion {
|
||||
id: "target".to_string(),
|
||||
header: "Target".to_string(),
|
||||
question: "Which target?".to_string(),
|
||||
is_other: true,
|
||||
is_secret: false,
|
||||
options: Some(vec![ToolRequestUserInputOption {
|
||||
label: "Core".to_string(),
|
||||
description: "Inspect core".to_string(),
|
||||
}]),
|
||||
}],
|
||||
auto_resolution_ms: None,
|
||||
};
|
||||
let mut input = Cursor::new(b"9\no\nSDK wrapper\n");
|
||||
let mut output = Vec::new();
|
||||
|
||||
let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
ToolRequestUserInputResponse {
|
||||
answers: HashMap::from([(
|
||||
"target".to_string(),
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["user_note: SDK wrapper".to_string()],
|
||||
},
|
||||
)]),
|
||||
}
|
||||
);
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("Invalid selection; try again."));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<()>
|
||||
{
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>",
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(®istration_url, auth_route_config)?;
|
||||
let runtime_id = retry_registration(|| async {
|
||||
register_agent_identity(
|
||||
&client,
|
||||
@@ -200,7 +212,7 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
|
||||
chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp,
|
||||
task_id: None,
|
||||
};
|
||||
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
|
||||
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config)
|
||||
.await
|
||||
.map_err(|err| classify_bootstrap_error("agent task registration", err))
|
||||
}
|
||||
@@ -208,9 +220,12 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
|
||||
pub(super) async fn verified_record_from_jwt(
|
||||
jwt: &str,
|
||||
chatgpt_base_url: &str,
|
||||
auth_route_config: Option<&AuthRouteConfig>,
|
||||
) -> std::io::Result<AgentIdentityAuthRecord> {
|
||||
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?;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,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}")))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -35,7 +35,7 @@ pub enum PlanType {
|
||||
pub enum ProviderAccount {
|
||||
ApiKey,
|
||||
Chatgpt {
|
||||
email: String,
|
||||
email: Option<String>,
|
||||
plan_type: PlanType,
|
||||
},
|
||||
AmazonBedrock {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+14
-13
@@ -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
Reference in New Issue
Block a user