diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f6f21761a0..dc616bd9c9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2263,6 +2263,7 @@ dependencies = [ "serde_json", "tokio", "url", + "wiremock", ] [[package]] diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index 013628ccd5..512d960617 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -28,3 +28,4 @@ codex-protocol = { workspace = true } [dev-dependencies] pretty_assertions = "1" tokio = { workspace = true, features = ["macros", "rt"] } +wiremock = { workspace = true } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index b183de6d42..97e3bdaee9 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,5 +1,6 @@ use crate::types::AccountsCheckResponse; use crate::types::CodeTaskDetailsResponse; +use crate::types::CodexUserSettingsResponse; use crate::types::CodexWorkspaceMessagesResponse; use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; @@ -454,6 +455,26 @@ impl Client { .map_err(RequestError::from) } + /// Fetch authenticated Codex user settings from the active backend route. + /// + /// Uses `GET /api/codex/settings/user` for Codex API hosts and + /// `GET /wham/settings/user` for ChatGPT `backend-api` hosts. + pub async fn get_user_settings( + &self, + ) -> std::result::Result { + let url = self.user_settings_url(); + let req = self + .request(Method::GET, &url) + .headers(self.headers()) + .header( + CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-store"), + ); + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + .map_err(RequestError::from) + } + pub async fn list_workspace_messages( &self, ) -> std::result::Result { @@ -613,6 +634,13 @@ impl Client { } } + fn user_settings_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/settings/user", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/settings/user", self.base_url), + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -695,6 +723,12 @@ mod tests { use codex_backend_openapi_models::models::RateLimitReachedKind; use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType; use pretty_assertions::assert_eq; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header_regex; + use wiremock::matchers::method; + use wiremock::matchers::path; #[test] fn map_plan_type_supports_usage_based_business_variants() { @@ -1025,6 +1059,90 @@ mod tests { ); } + #[tokio::test] + async fn user_settings_request_uses_expected_paths_and_revalidates_cached_responses() { + let server = MockServer::start().await; + for (request_path, commit_attribution_enabled) in [ + ("/api/codex/settings/user", true), + ("/backend-api/wham/settings/user", false), + ] { + Mock::given(method("GET")) + .and(path(request_path)) + .and(header_regex("cache-control", "^no-cache, no-store$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "commit_attribution_enabled": commit_attribution_enabled, + }))) + .expect(1) + .mount(&server) + .await; + } + + let codex_response = Client::new( + server.uri(), + HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), + ) + .get_user_settings() + .await + .unwrap(); + let chatgpt_response = Client::new( + format!("{}/backend-api", server.uri()), + HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), + ) + .get_user_settings() + .await + .unwrap(); + + assert_eq!( + [codex_response, chatgpt_response], + [ + CodexUserSettingsResponse { + commit_attribution_enabled: true, + }, + CodexUserSettingsResponse { + commit_attribution_enabled: false, + }, + ] + ); + } + + #[test] + fn user_settings_missing_attribution_policy_defaults_to_disabled() { + assert_eq!( + serde_json::from_value::(serde_json::json!({})).unwrap(), + CodexUserSettingsResponse { + commit_attribution_enabled: false, + } + ); + } + + #[test] + fn authenticated_user_settings_client_uses_active_workspace_headers() { + let auth = CodexAuth::from_external_chatgpt_tokens( + "e30.e30.c2ln", + "workspace-123", + Some("enterprise"), + ) + .unwrap(); + let client = Client::from_auth( + "https://chatgpt.com/backend-api", + &auth, + HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), + ); + let headers = client.headers(); + + assert_eq!( + [ + headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()), + ], + [Some("Bearer e30.e30.c2ln"), Some("workspace-123")] + ); + } + fn test_client(base_url: &str, path_style: PathStyle) -> Client { Client { base_url: base_url.to_string(), diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 1e244e7d03..91aa10488d 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -8,6 +8,7 @@ pub use types::AccountEntry; pub use types::AccountsCheckResponse; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; +pub use types::CodexUserSettingsResponse; pub use types::CodexWorkspaceMessage; pub use types::CodexWorkspaceMessageType; pub use types::CodexWorkspaceMessagesResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 615fefd5fc..69185887ba 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -60,6 +60,16 @@ pub struct CodexWorkspaceMessagesResponse { pub messages: Vec, } +/// Authenticated Codex user settings used by CLI runtime policy. +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +pub struct CodexUserSettingsResponse { + /// Server-computed effective commit-attribution policy. + /// + /// Older backend responses omit this field, which safely defaults to disabled. + #[serde(default)] + pub commit_attribution_enabled: bool, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] pub struct CodexWorkspaceMessage { pub message_id: String,