SaylorTwift HF Staff commited on
Commit
52a9af3
·
verified ·
1 Parent(s): bb5a19b

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. codex-rs/cloud-config/src/backend.rs +136 -0
  2. codex-rs/cloud-config/src/bundle_loader.rs +132 -0
  3. codex-rs/cloud-config/src/cache.rs +253 -0
  4. codex-rs/cloud-config/src/cache_tests.rs +206 -0
  5. codex-rs/cloud-config/src/lib.rs +15 -0
  6. codex-rs/cloud-config/src/metrics.rs +95 -0
  7. codex-rs/cloud-config/src/service.rs +538 -0
  8. codex-rs/cloud-config/src/service_tests.rs +1658 -0
  9. codex-rs/cloud-config/src/validation.rs +48 -0
  10. codex-rs/cloud-config/src/validation_tests.rs +51 -0
  11. codex-rs/codex-backend-openapi-models/src/lib.rs +6 -0
  12. codex-rs/codex-backend-openapi-models/src/models/additional_rate_limit_details.rs +38 -0
  13. codex-rs/codex-backend-openapi-models/src/models/analytics.rs +230 -0
  14. codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs +42 -0
  15. codex-rs/codex-backend-openapi-models/src/models/config_bundle_response.rs +40 -0
  16. codex-rs/codex-backend-openapi-models/src/models/config_file_response.rs +40 -0
  17. codex-rs/codex-backend-openapi-models/src/models/credit_status_details.rs +52 -0
  18. codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs +40 -0
  19. codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs +33 -0
  20. codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs +40 -0
  21. codex-rs/codex-backend-openapi-models/src/models/delivered_toml_fragment.rs +28 -0
  22. codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs +40 -0
  23. codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs +77 -0
  24. codex-rs/codex-backend-openapi-models/src/models/mod.rs +68 -0
  25. codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs +30 -0
  26. codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs +46 -0
  27. codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +143 -0
  28. codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs +39 -0
  29. codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs +60 -0
  30. codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs +35 -0
  31. codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs +63 -0
  32. codex-rs/codex-backend-openapi-models/src/models/task_response.rs +62 -0
  33. codex-rs/config-schema/src/main.rs +22 -0
  34. codex-rs/config/src/application_requirements.rs +71 -0
  35. codex-rs/config/src/bedrock_runtime_tests.rs +48 -0
  36. codex-rs/config/src/browser_computer_use_requirements_tests.rs +45 -0
  37. codex-rs/config/src/browser_use.rs +26 -0
  38. codex-rs/config/src/cloud_config_bundle.rs +237 -0
  39. codex-rs/config/src/cloud_config_layers_tests.rs +226 -0
  40. codex-rs/config/src/config_layer_source.rs +109 -0
  41. codex-rs/config/src/hooks_tests.rs +363 -0
  42. codex-rs/config/src/host_name.rs +119 -0
  43. codex-rs/config/src/in_app_browser_requirements_tests.rs +144 -0
  44. codex-rs/config/src/loader/README.md +90 -0
  45. codex-rs/config/src/loader/layer_io.rs +233 -0
  46. codex-rs/config/src/loader/local.rs +398 -0
  47. codex-rs/config/src/loader/macos.rs +230 -0
  48. codex-rs/config/src/loader/managed_project_discovery_tests.rs +348 -0
  49. codex-rs/config/src/loader/managed_requirements.rs +121 -0
  50. codex-rs/config/src/loader/mod.rs +1982 -0
codex-rs/cloud-config/src/backend.rs ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_backend_client::Client as BackendClient;
2
+ use codex_backend_client::ConfigBundleResponse;
3
+ use codex_backend_client::DeliveredTomlFragment;
4
+ use codex_config::CloudConfigBundle;
5
+ use codex_config::CloudConfigFragment;
6
+ use codex_config::CloudConfigTomlBundle;
7
+ use codex_config::CloudRequirementsFragment;
8
+ use codex_config::CloudRequirementsTomlBundle;
9
+ use codex_http_client::HttpClientFactory;
10
+ use codex_login::CodexAuth;
11
+ use std::future::Future;
12
+
13
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
14
+ pub(crate) enum RetryableFailureKind {
15
+ Request { status_code: Option<u16> },
16
+ }
17
+
18
+ impl RetryableFailureKind {
19
+ pub(crate) fn status_code(self) -> Option<u16> {
20
+ match self {
21
+ Self::Request { status_code } => status_code,
22
+ }
23
+ }
24
+ }
25
+
26
+ #[derive(Clone, Debug, Eq, PartialEq)]
27
+ pub(crate) enum BundleRequestError {
28
+ Retryable(RetryableFailureKind),
29
+ Unauthorized {
30
+ status_code: Option<u16>,
31
+ message: String,
32
+ },
33
+ }
34
+
35
+ /// Retrieves one cloud config bundle from the backend.
36
+ ///
37
+ /// Implementations should return the backend-selected bundle exactly as delivered and leave
38
+ /// validation, caching, and config/requirements parsing decisions to the service layer.
39
+ pub(crate) trait BundleClient: Send + Sync {
40
+ fn get_bundle(
41
+ &self,
42
+ auth: &CodexAuth,
43
+ ) -> impl Future<Output = Result<CloudConfigBundle, BundleRequestError>> + Send;
44
+ }
45
+
46
+ pub(crate) struct BackendBundleClient {
47
+ base_url: String,
48
+ http_client_factory: HttpClientFactory,
49
+ }
50
+
51
+ impl BackendBundleClient {
52
+ pub(crate) fn new(base_url: String, http_client_factory: HttpClientFactory) -> Self {
53
+ Self {
54
+ base_url,
55
+ http_client_factory,
56
+ }
57
+ }
58
+ }
59
+
60
+ impl BundleClient for BackendBundleClient {
61
+ async fn get_bundle(&self, auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
62
+ let client = BackendClient::from_auth(
63
+ self.base_url.clone(),
64
+ auth,
65
+ self.http_client_factory.clone(),
66
+ );
67
+
68
+ let response = client
69
+ .get_config_bundle()
70
+ .await
71
+ .inspect_err(|err| {
72
+ tracing::warn!(error = %err, "Failed to fetch cloud config bundle");
73
+ })
74
+ .map_err(|err| {
75
+ let status_code = err.status().map(|status| status.as_u16());
76
+ if err.is_unauthorized() {
77
+ BundleRequestError::Unauthorized {
78
+ status_code,
79
+ message: err.to_string(),
80
+ }
81
+ } else {
82
+ BundleRequestError::Retryable(RetryableFailureKind::Request { status_code })
83
+ }
84
+ })?;
85
+
86
+ Ok(bundle_from_response(response))
87
+ }
88
+ }
89
+
90
+ pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle {
91
+ let config_toml = response
92
+ .config_toml
93
+ .flatten()
94
+ .map(|config_toml| *config_toml)
95
+ .and_then(|config_toml| config_toml.enterprise_managed.flatten())
96
+ .unwrap_or_default()
97
+ .into_iter()
98
+ .map(config_fragment_from_delivered)
99
+ .collect();
100
+ let requirements_toml = response
101
+ .requirements_toml
102
+ .flatten()
103
+ .map(|requirements_toml| *requirements_toml)
104
+ .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten())
105
+ .unwrap_or_default()
106
+ .into_iter()
107
+ .map(requirements_fragment_from_delivered)
108
+ .collect();
109
+
110
+ CloudConfigBundle {
111
+ config_toml: CloudConfigTomlBundle {
112
+ enterprise_managed: config_toml,
113
+ },
114
+ requirements_toml: CloudRequirementsTomlBundle {
115
+ enterprise_managed: requirements_toml,
116
+ },
117
+ }
118
+ }
119
+
120
+ fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment {
121
+ CloudConfigFragment {
122
+ id: fragment.id,
123
+ name: fragment.name,
124
+ contents: fragment.contents,
125
+ }
126
+ }
127
+
128
+ fn requirements_fragment_from_delivered(
129
+ fragment: DeliveredTomlFragment,
130
+ ) -> CloudRequirementsFragment {
131
+ CloudRequirementsFragment {
132
+ id: fragment.id,
133
+ name: fragment.name,
134
+ contents: fragment.contents,
135
+ }
136
+ }
codex-rs/cloud-config/src/bundle_loader.rs ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::backend::BackendBundleClient;
2
+ use crate::backend::BundleClient;
3
+ use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
4
+ use crate::service::CloudConfigBundleService;
5
+ use codex_config::CloudConfigBundleLoader;
6
+ use codex_http_client::HttpClientFactory;
7
+ use codex_login::AuthConfig;
8
+ use codex_login::AuthManager;
9
+ use std::path::PathBuf;
10
+ use std::sync::Arc;
11
+ use std::sync::Mutex;
12
+ use std::sync::OnceLock;
13
+ use tokio::task::AbortHandle;
14
+ use tokio::task::JoinHandle;
15
+
16
+ fn refresher_task_slot() -> &'static Mutex<Option<AbortHandle>> {
17
+ static REFRESHER_TASK: OnceLock<Mutex<Option<AbortHandle>>> = OnceLock::new();
18
+ REFRESHER_TASK.get_or_init(|| Mutex::new(None))
19
+ }
20
+
21
+ pub(crate) fn replace_refresh_task(slot: &Mutex<Option<AbortHandle>>, next: AbortHandle) {
22
+ let mut guard = slot.lock().unwrap_or_else(|err| {
23
+ tracing::warn!("cloud config bundle refresher task slot was poisoned");
24
+ err.into_inner()
25
+ });
26
+ if let Some(previous) = guard.replace(next) {
27
+ previous.abort();
28
+ }
29
+ }
30
+
31
+ struct CloudConfigBundleLoaderLifetime<C> {
32
+ service: Arc<CloudConfigBundleService<C>>,
33
+ refresh_task: JoinHandle<()>,
34
+ }
35
+
36
+ impl<C> Drop for CloudConfigBundleLoaderLifetime<C> {
37
+ fn drop(&mut self) {
38
+ self.refresh_task.abort();
39
+ }
40
+ }
41
+
42
+ pub fn cloud_config_bundle_loader(
43
+ auth_manager: Arc<AuthManager>,
44
+ chatgpt_base_url: String,
45
+ codex_home: PathBuf,
46
+ http_client_factory: HttpClientFactory,
47
+ ) -> CloudConfigBundleLoader {
48
+ let service = CloudConfigBundleService::new(
49
+ auth_manager,
50
+ Arc::new(BackendBundleClient::new(
51
+ chatgpt_base_url,
52
+ http_client_factory,
53
+ )),
54
+ codex_home,
55
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
56
+ );
57
+ let (loader, refresh_task) = cloud_config_bundle_loader_for_service(service);
58
+ replace_refresh_task(refresher_task_slot(), refresh_task);
59
+ loader
60
+ }
61
+
62
+ pub(crate) fn cloud_config_bundle_loader_for_service<C>(
63
+ service: CloudConfigBundleService<C>,
64
+ ) -> (CloudConfigBundleLoader, AbortHandle)
65
+ where
66
+ C: BundleClient + 'static,
67
+ {
68
+ let service = Arc::new(service);
69
+ let background_service = Arc::clone(&service);
70
+ let refresh_task = tokio::spawn(async move {
71
+ let _ = background_service.get_latest().await;
72
+ background_service.refresh_cache_in_background().await;
73
+ });
74
+ let abort_handle = refresh_task.abort_handle();
75
+ let lifetime = Arc::new(CloudConfigBundleLoaderLifetime {
76
+ service,
77
+ refresh_task,
78
+ });
79
+
80
+ let loader = CloudConfigBundleLoader::from_getter(move || {
81
+ let lifetime = Arc::clone(&lifetime);
82
+ async move { lifetime.service.get_latest().await }
83
+ });
84
+ (loader, abort_handle)
85
+ }
86
+
87
+ pub async fn cloud_config_bundle_loader_for_storage(
88
+ auth_config: AuthConfig,
89
+ enable_codex_api_key_env: bool,
90
+ ) -> std::io::Result<CloudConfigBundleLoader> {
91
+ let service =
92
+ cloud_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env).await?;
93
+ let (loader, refresh_task) = cloud_config_bundle_loader_for_service(service);
94
+ replace_refresh_task(refresher_task_slot(), refresh_task);
95
+ Ok(loader)
96
+ }
97
+
98
+ /// Fetches directly from the network on each load, without reading or writing
99
+ /// the disk cache or starting a background refresher.
100
+ pub async fn cloud_config_bundle_loader_for_storage_without_cache(
101
+ auth_config: AuthConfig,
102
+ enable_codex_api_key_env: bool,
103
+ ) -> std::io::Result<CloudConfigBundleLoader> {
104
+ let service = Arc::new(
105
+ cloud_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env)
106
+ .await?
107
+ .without_cache(),
108
+ );
109
+ Ok(CloudConfigBundleLoader::from_getter(move || {
110
+ let service = Arc::clone(&service);
111
+ async move { service.load_startup_bundle_with_timeout().await }
112
+ }))
113
+ }
114
+
115
+ async fn cloud_config_bundle_service_for_storage(
116
+ auth_config: AuthConfig,
117
+ enable_codex_api_key_env: bool,
118
+ ) -> std::io::Result<CloudConfigBundleService<BackendBundleClient>> {
119
+ let auth_manager =
120
+ AuthManager::shared_from_auth_config(auth_config.clone(), enable_codex_api_key_env).await?;
121
+ Ok(CloudConfigBundleService::new(
122
+ auth_manager,
123
+ Arc::new(BackendBundleClient::new(
124
+ auth_config
125
+ .chatgpt_base_url
126
+ .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()),
127
+ auth_config.auth_route_config.http_client_factory().clone(),
128
+ )),
129
+ auth_config.codex_home,
130
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
131
+ ))
132
+ }
codex-rs/cloud-config/src/cache.rs ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Signed on-disk cache for cloud config bundles.
2
+ //!
3
+ //! The cache is scoped to the authenticated ChatGPT user and account, has a
4
+ //! short TTL, and is HMAC-signed so malformed or edited files fail closed.
5
+
6
+ use base64::Engine;
7
+ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
8
+ use chrono::DateTime;
9
+ use chrono::Duration as ChronoDuration;
10
+ use chrono::Utc;
11
+ use codex_config::AbsolutePathBuf;
12
+ use codex_config::CloudConfigBundle;
13
+ use hmac::Hmac;
14
+ use hmac::Mac;
15
+ use serde::Deserialize;
16
+ use serde::Serialize;
17
+ use sha2::Sha256;
18
+ use std::path::Path;
19
+ use std::time::Duration;
20
+ use thiserror::Error;
21
+ use tokio::fs;
22
+
23
+ const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1;
24
+ pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json";
25
+ const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
26
+ const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] =
27
+ b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e";
28
+ const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] =
29
+ &[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY];
30
+
31
+ type HmacSha256 = Hmac<Sha256>;
32
+
33
+ #[derive(Clone)]
34
+ pub(super) struct CloudConfigBundleCache {
35
+ path: AbsolutePathBuf,
36
+ }
37
+
38
+ impl CloudConfigBundleCache {
39
+ pub(super) fn new(codex_home: AbsolutePathBuf) -> Self {
40
+ Self {
41
+ path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME),
42
+ }
43
+ }
44
+
45
+ pub(super) fn path(&self) -> &Path {
46
+ &self.path
47
+ }
48
+
49
+ pub(super) async fn load(
50
+ &self,
51
+ chatgpt_user_id: Option<&str>,
52
+ account_id: Option<&str>,
53
+ ) -> Result<CloudConfigBundleCacheSignedPayload, CacheLoadStatus> {
54
+ let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else {
55
+ return Err(CacheLoadStatus::AuthIdentityIncomplete);
56
+ };
57
+
58
+ let bytes = match fs::read(&self.path).await {
59
+ Ok(bytes) => bytes,
60
+ Err(err) => {
61
+ if err.kind() != std::io::ErrorKind::NotFound {
62
+ return Err(CacheLoadStatus::CacheReadFailed(err.to_string()));
63
+ }
64
+ return Err(CacheLoadStatus::CacheFileNotFound);
65
+ }
66
+ };
67
+
68
+ let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) {
69
+ Ok(cache_file) => cache_file,
70
+ Err(err) => {
71
+ return Err(CacheLoadStatus::CacheParseFailed(err.to_string()));
72
+ }
73
+ };
74
+ let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) {
75
+ Some(payload_bytes) => payload_bytes,
76
+ None => {
77
+ return Err(CacheLoadStatus::CacheParseFailed(
78
+ "failed to serialize cache payload".to_string(),
79
+ ));
80
+ }
81
+ };
82
+ if !verify_cache_signature(&payload_bytes, &cache_file.signature) {
83
+ return Err(CacheLoadStatus::CacheSignatureInvalid);
84
+ }
85
+ if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION {
86
+ return Err(CacheLoadStatus::CacheVersionUnsupported(
87
+ cache_file.signed_payload.version,
88
+ ));
89
+ }
90
+
91
+ let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = (
92
+ cache_file.signed_payload.chatgpt_user_id.as_deref(),
93
+ cache_file.signed_payload.account_id.as_deref(),
94
+ ) else {
95
+ return Err(CacheLoadStatus::CacheIdentityIncomplete);
96
+ };
97
+
98
+ if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id {
99
+ return Err(CacheLoadStatus::CacheIdentityMismatch);
100
+ }
101
+
102
+ if cache_file.signed_payload.expires_at <= Utc::now() {
103
+ return Err(CacheLoadStatus::CacheExpired);
104
+ }
105
+
106
+ Ok(cache_file.signed_payload)
107
+ }
108
+
109
+ pub(super) fn log_load_status(&self, status: &CacheLoadStatus) {
110
+ if matches!(status, CacheLoadStatus::CacheFileNotFound) {
111
+ return;
112
+ }
113
+
114
+ let warn = matches!(
115
+ status,
116
+ CacheLoadStatus::CacheReadFailed(_)
117
+ | CacheLoadStatus::CacheParseFailed(_)
118
+ | CacheLoadStatus::CacheSignatureInvalid
119
+ );
120
+
121
+ if warn {
122
+ tracing::warn!(path = %self.path.display(), "{status}");
123
+ } else {
124
+ tracing::info!(path = %self.path.display(), "{status}");
125
+ }
126
+ }
127
+
128
+ pub(super) async fn save(
129
+ &self,
130
+ chatgpt_user_id: Option<String>,
131
+ account_id: Option<String>,
132
+ bundle: CloudConfigBundle,
133
+ ) -> Result<(), CloudConfigBundleCacheError> {
134
+ let now = Utc::now();
135
+ let expires_at = now
136
+ .checked_add_signed(
137
+ ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL)
138
+ .map_err(|_| CloudConfigBundleCacheError)?,
139
+ )
140
+ .ok_or(CloudConfigBundleCacheError)?;
141
+ let signed_payload = CloudConfigBundleCacheSignedPayload {
142
+ version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
143
+ cached_at: now,
144
+ expires_at,
145
+ chatgpt_user_id,
146
+ account_id,
147
+ bundle,
148
+ };
149
+ let payload_bytes =
150
+ cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?;
151
+ let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile {
152
+ signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?,
153
+ signed_payload,
154
+ })
155
+ .map_err(|_| CloudConfigBundleCacheError)?;
156
+
157
+ if let Some(parent) = self.path.parent() {
158
+ fs::create_dir_all(parent)
159
+ .await
160
+ .map_err(|_| CloudConfigBundleCacheError)?;
161
+ }
162
+
163
+ fs::write(&self.path, serialized)
164
+ .await
165
+ .map_err(|_| CloudConfigBundleCacheError)?;
166
+ Ok(())
167
+ }
168
+ }
169
+
170
+ #[derive(Clone, Debug, Eq, Error, PartialEq)]
171
+ pub(super) enum CacheLoadStatus {
172
+ #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")]
173
+ AuthIdentityIncomplete,
174
+ #[error("Cloud config bundle cache file not found.")]
175
+ CacheFileNotFound,
176
+ #[error("Failed to read cloud config bundle cache: {0}.")]
177
+ CacheReadFailed(String),
178
+ #[error("Failed to parse cloud config bundle cache: {0}.")]
179
+ CacheParseFailed(String),
180
+ #[error("Cloud config bundle cache failed signature verification.")]
181
+ CacheSignatureInvalid,
182
+ #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")]
183
+ CacheIdentityIncomplete,
184
+ #[error("Ignoring cloud config bundle cache for different auth identity.")]
185
+ CacheIdentityMismatch,
186
+ #[error("Ignoring cloud config bundle cache with unsupported version {0}.")]
187
+ CacheVersionUnsupported(u32),
188
+ #[error("Cloud config bundle cache expired.")]
189
+ CacheExpired,
190
+ #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")]
191
+ CacheInvalidBundle,
192
+ }
193
+
194
+ #[derive(Debug, Error)]
195
+ #[error("failed to write cloud config bundle cache")]
196
+ pub(super) struct CloudConfigBundleCacheError;
197
+
198
+ #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199
+ pub(super) struct CloudConfigBundleCacheFile {
200
+ pub(super) signed_payload: CloudConfigBundleCacheSignedPayload,
201
+ pub(super) signature: String,
202
+ }
203
+
204
+ #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
205
+ pub(super) struct CloudConfigBundleCacheSignedPayload {
206
+ pub(super) version: u32,
207
+ pub(super) cached_at: DateTime<Utc>,
208
+ pub(super) expires_at: DateTime<Utc>,
209
+ pub(super) chatgpt_user_id: Option<String>,
210
+ pub(super) account_id: Option<String>,
211
+ pub(super) bundle: CloudConfigBundle,
212
+ }
213
+
214
+ pub(super) fn cache_payload_bytes(
215
+ payload: &CloudConfigBundleCacheSignedPayload,
216
+ ) -> Option<Vec<u8>> {
217
+ serde_json::to_vec(&payload).ok()
218
+ }
219
+
220
+ pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option<String> {
221
+ let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?;
222
+ mac.update(payload_bytes);
223
+ let signature = mac.finalize().into_bytes();
224
+ Some(BASE64_STANDARD.encode(signature))
225
+ }
226
+
227
+ pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool {
228
+ let signature_bytes = match BASE64_STANDARD.decode(signature) {
229
+ Ok(signature_bytes) => signature_bytes,
230
+ Err(_) => return false,
231
+ };
232
+
233
+ CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS
234
+ .iter()
235
+ .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key))
236
+ }
237
+
238
+ fn verify_cache_signature_with_key(
239
+ payload_bytes: &[u8],
240
+ signature_bytes: &[u8],
241
+ key: &[u8],
242
+ ) -> bool {
243
+ let mut mac = match HmacSha256::new_from_slice(key) {
244
+ Ok(mac) => mac,
245
+ Err(_) => return false,
246
+ };
247
+ mac.update(payload_bytes);
248
+ mac.verify_slice(signature_bytes).is_ok()
249
+ }
250
+
251
+ #[cfg(test)]
252
+ #[path = "cache_tests.rs"]
253
+ mod tests;
codex-rs/cloud-config/src/cache_tests.rs ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use codex_config::AbsolutePathBuf;
3
+ use codex_config::CloudConfigFragment;
4
+ use codex_config::CloudConfigTomlBundle;
5
+ use codex_config::CloudRequirementsFragment;
6
+ use codex_config::CloudRequirementsTomlBundle;
7
+ use pretty_assertions::assert_eq;
8
+ use std::path::Path;
9
+ use tempfile::tempdir;
10
+
11
+ fn test_bundle() -> CloudConfigBundle {
12
+ CloudConfigBundle {
13
+ config_toml: CloudConfigTomlBundle {
14
+ enterprise_managed: vec![CloudConfigFragment {
15
+ id: "cfg_1".to_string(),
16
+ name: "Base config".to_string(),
17
+ contents: "model = \"gpt-5\"".to_string(),
18
+ }],
19
+ },
20
+ requirements_toml: CloudRequirementsTomlBundle {
21
+ enterprise_managed: vec![CloudRequirementsFragment {
22
+ id: "req_1".to_string(),
23
+ name: "Base requirements".to_string(),
24
+ contents: "allowed_approval_policies = [\"never\"]".to_string(),
25
+ }],
26
+ },
27
+ }
28
+ }
29
+
30
+ fn signed_cache_file(
31
+ signed_payload: CloudConfigBundleCacheSignedPayload,
32
+ ) -> CloudConfigBundleCacheFile {
33
+ let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes");
34
+ CloudConfigBundleCacheFile {
35
+ signature: sign_cache_payload(&payload_bytes).expect("signature"),
36
+ signed_payload,
37
+ }
38
+ }
39
+
40
+ fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload {
41
+ let cached_at = Utc::now();
42
+ CloudConfigBundleCacheSignedPayload {
43
+ version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
44
+ cached_at,
45
+ expires_at: cached_at + ChronoDuration::minutes(30),
46
+ chatgpt_user_id: Some("user-12345".to_string()),
47
+ account_id: Some("account-12345".to_string()),
48
+ bundle: test_bundle(),
49
+ }
50
+ }
51
+
52
+ fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) {
53
+ std::fs::write(
54
+ cache.path(),
55
+ serde_json::to_vec_pretty(cache_file).expect("serialize cache"),
56
+ )
57
+ .expect("write cache");
58
+ }
59
+
60
+ fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache {
61
+ CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/"))
62
+ }
63
+
64
+ #[tokio::test]
65
+ async fn save_writes_signed_payload_and_loads_for_matching_identity() {
66
+ let codex_home = tempdir().expect("tempdir");
67
+ let cache = create_test_cache(codex_home.path());
68
+ let bundle = test_bundle();
69
+
70
+ cache
71
+ .save(
72
+ Some("user-12345".to_string()),
73
+ Some("account-12345".to_string()),
74
+ bundle.clone(),
75
+ )
76
+ .await
77
+ .expect("save cache");
78
+
79
+ let cache_file: CloudConfigBundleCacheFile =
80
+ serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache"))
81
+ .expect("parse cache");
82
+ assert!(
83
+ cache_file.signed_payload.expires_at
84
+ <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(60)
85
+ );
86
+ assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
87
+ assert_eq!(
88
+ cache_file,
89
+ signed_cache_file(CloudConfigBundleCacheSignedPayload {
90
+ version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
91
+ cached_at: cache_file.signed_payload.cached_at,
92
+ expires_at: cache_file.signed_payload.expires_at,
93
+ chatgpt_user_id: Some("user-12345".to_string()),
94
+ account_id: Some("account-12345".to_string()),
95
+ bundle,
96
+ })
97
+ );
98
+
99
+ assert_eq!(
100
+ cache.load(Some("user-12345"), Some("account-12345")).await,
101
+ Ok(cache_file.signed_payload)
102
+ );
103
+ }
104
+
105
+ #[tokio::test]
106
+ async fn load_rejects_missing_request_identity_before_reading_cache_file() {
107
+ let codex_home = tempdir().expect("tempdir");
108
+ let cache = create_test_cache(codex_home.path());
109
+
110
+ assert_eq!(
111
+ cache
112
+ .load(/*chatgpt_user_id*/ None, Some("account-12345"))
113
+ .await,
114
+ Err(CacheLoadStatus::AuthIdentityIncomplete)
115
+ );
116
+ assert_eq!(
117
+ cache.load(Some("user-12345"), /*account_id*/ None).await,
118
+ Err(CacheLoadStatus::AuthIdentityIncomplete)
119
+ );
120
+ }
121
+
122
+ #[tokio::test]
123
+ async fn load_reports_missing_and_malformed_cache_files() {
124
+ let codex_home = tempdir().expect("tempdir");
125
+ let cache = create_test_cache(codex_home.path());
126
+
127
+ assert_eq!(
128
+ cache.load(Some("user-12345"), Some("account-12345")).await,
129
+ Err(CacheLoadStatus::CacheFileNotFound)
130
+ );
131
+
132
+ std::fs::write(cache.path(), "{").expect("write malformed cache");
133
+ assert!(matches!(
134
+ cache.load(Some("user-12345"), Some("account-12345")).await,
135
+ Err(CacheLoadStatus::CacheParseFailed(_))
136
+ ));
137
+ }
138
+
139
+ #[tokio::test]
140
+ async fn load_rejects_tampered_payload() {
141
+ let codex_home = tempdir().expect("tempdir");
142
+ let cache = create_test_cache(codex_home.path());
143
+ let mut cache_file = signed_cache_file(valid_signed_payload());
144
+ cache_file
145
+ .signed_payload
146
+ .bundle
147
+ .requirements_toml
148
+ .enterprise_managed[0]
149
+ .contents = "allowed_approval_policies = [\"on-request\"]".to_string();
150
+ write_cache_file(&cache, &cache_file);
151
+
152
+ assert_eq!(
153
+ cache.load(Some("user-12345"), Some("account-12345")).await,
154
+ Err(CacheLoadStatus::CacheSignatureInvalid)
155
+ );
156
+ }
157
+
158
+ #[tokio::test]
159
+ async fn load_rejects_cache_for_incomplete_or_different_identity() {
160
+ let codex_home = tempdir().expect("tempdir");
161
+ let cache = create_test_cache(codex_home.path());
162
+ let cache_file = signed_cache_file(valid_signed_payload());
163
+ write_cache_file(&cache, &cache_file);
164
+
165
+ assert_eq!(
166
+ cache.load(Some("user-99999"), Some("account-12345")).await,
167
+ Err(CacheLoadStatus::CacheIdentityMismatch)
168
+ );
169
+
170
+ let mut signed_payload = valid_signed_payload();
171
+ signed_payload.chatgpt_user_id = None;
172
+ write_cache_file(&cache, &signed_cache_file(signed_payload));
173
+
174
+ assert_eq!(
175
+ cache.load(Some("user-12345"), Some("account-12345")).await,
176
+ Err(CacheLoadStatus::CacheIdentityIncomplete)
177
+ );
178
+ }
179
+
180
+ #[tokio::test]
181
+ async fn load_rejects_expired_cache() {
182
+ let codex_home = tempdir().expect("tempdir");
183
+ let cache = create_test_cache(codex_home.path());
184
+ let mut signed_payload = valid_signed_payload();
185
+ signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1);
186
+ write_cache_file(&cache, &signed_cache_file(signed_payload));
187
+
188
+ assert_eq!(
189
+ cache.load(Some("user-12345"), Some("account-12345")).await,
190
+ Err(CacheLoadStatus::CacheExpired)
191
+ );
192
+ }
193
+
194
+ #[tokio::test]
195
+ async fn load_rejects_unsupported_cache_version() {
196
+ let codex_home = tempdir().expect("tempdir");
197
+ let cache = create_test_cache(codex_home.path());
198
+ let mut signed_payload = valid_signed_payload();
199
+ signed_payload.version = 2;
200
+ write_cache_file(&cache, &signed_cache_file(signed_payload));
201
+
202
+ assert_eq!(
203
+ cache.load(Some("user-12345"), Some("account-12345")).await,
204
+ Err(CacheLoadStatus::CacheVersionUnsupported(2))
205
+ );
206
+ }
codex-rs/cloud-config/src/lib.rs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Cloud-hosted configuration data for Codex.
2
+ //!
3
+ //! This crate owns transport, caching, and refresh behavior for cloud-delivered
4
+ //! config data. Parsing and composition remain in `codex-config`.
5
+
6
+ mod backend;
7
+ mod bundle_loader;
8
+ mod cache;
9
+ mod metrics;
10
+ mod service;
11
+ mod validation;
12
+
13
+ pub use bundle_loader::cloud_config_bundle_loader;
14
+ pub use bundle_loader::cloud_config_bundle_loader_for_storage;
15
+ pub use bundle_loader::cloud_config_bundle_loader_for_storage_without_cache;
codex-rs/cloud-config/src/metrics.rs ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_config::CloudConfigBundle;
2
+
3
+ const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt";
4
+ const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final";
5
+ const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load";
6
+
7
+ pub(crate) fn emit_fetch_attempt_metric(
8
+ trigger: &str,
9
+ attempt: usize,
10
+ outcome: &str,
11
+ status_code: Option<u16>,
12
+ ) {
13
+ let attempt_tag = attempt.to_string();
14
+ let status_code_tag = status_code_tag(status_code);
15
+ emit_metric(
16
+ CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC,
17
+ vec![
18
+ ("trigger", trigger.to_string()),
19
+ ("attempt", attempt_tag),
20
+ ("outcome", outcome.to_string()),
21
+ ("status_code", status_code_tag),
22
+ ],
23
+ );
24
+ }
25
+
26
+ pub(crate) fn emit_fetch_final_metric(
27
+ trigger: &str,
28
+ outcome: &str,
29
+ reason: &str,
30
+ attempt_count: usize,
31
+ status_code: Option<u16>,
32
+ bundle: Option<&CloudConfigBundle>,
33
+ ) {
34
+ let attempt_count_tag = attempt_count.to_string();
35
+ let status_code_tag = status_code_tag(status_code);
36
+ emit_metric(
37
+ CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC,
38
+ vec![
39
+ ("trigger", trigger.to_string()),
40
+ ("outcome", outcome.to_string()),
41
+ ("reason", reason.to_string()),
42
+ ("attempt_count", attempt_count_tag),
43
+ ("status_code", status_code_tag),
44
+ ("bundle_shape", bundle_shape_tag(bundle)),
45
+ ],
46
+ );
47
+ }
48
+
49
+ pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) {
50
+ emit_metric(
51
+ CLOUD_CONFIG_BUNDLE_LOAD_METRIC,
52
+ vec![
53
+ ("trigger", trigger.to_string()),
54
+ ("outcome", outcome.to_string()),
55
+ ("bundle_shape", bundle_shape_tag(bundle)),
56
+ ],
57
+ );
58
+ }
59
+
60
+ pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String {
61
+ let Some(bundle) = bundle else {
62
+ return "none".to_string();
63
+ };
64
+
65
+ let mut sources = Vec::new();
66
+ if !bundle.config_toml.enterprise_managed.is_empty() {
67
+ sources.push("enterprise_config");
68
+ }
69
+ if !bundle.requirements_toml.enterprise_managed.is_empty() {
70
+ sources.push("enterprise_requirements");
71
+ }
72
+
73
+ if sources.is_empty() {
74
+ "empty".to_string()
75
+ } else {
76
+ sources.sort_unstable();
77
+ sources.join(",")
78
+ }
79
+ }
80
+
81
+ fn status_code_tag(status_code: Option<u16>) -> String {
82
+ status_code
83
+ .map(|status_code| status_code.to_string())
84
+ .unwrap_or_else(|| "none".to_string())
85
+ }
86
+
87
+ fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) {
88
+ if let Some(metrics) = codex_otel::global() {
89
+ let tag_refs = tags
90
+ .iter()
91
+ .map(|(key, value)| (*key, value.as_str()))
92
+ .collect::<Vec<_>>();
93
+ let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);
94
+ }
95
+ }
codex-rs/cloud-config/src/service.rs ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Cloud config bundle lifecycle orchestration.
2
+ //!
3
+ //! Startup loads a shared bundle from cache or backend, and background refresh
4
+ //! updates both the on-disk cache and the bundle observed by future config loads.
5
+ //! One-shot network loads can disable disk-cache reads and writes.
6
+
7
+ use crate::backend::BundleClient;
8
+ use crate::backend::BundleRequestError;
9
+ use crate::backend::RetryableFailureKind;
10
+ use crate::cache::CacheLoadStatus;
11
+ use crate::cache::CloudConfigBundleCache;
12
+ use crate::metrics::emit_fetch_attempt_metric;
13
+ use crate::metrics::emit_fetch_final_metric;
14
+ use crate::metrics::emit_load_metric;
15
+ use crate::validation::validate_bundle;
16
+ use codex_config::AbsolutePathBuf;
17
+ use codex_config::CloudConfigBundle;
18
+ use codex_config::CloudConfigBundleLoadError;
19
+ use codex_config::CloudConfigBundleLoadErrorCode;
20
+ use codex_core::util::backoff;
21
+ use codex_login::AuthManager;
22
+ use codex_login::CodexAuth;
23
+ use codex_login::RefreshTokenError;
24
+ use codex_login::UnauthorizedRecovery;
25
+ use codex_protocol::account::PlanType;
26
+ use std::path::PathBuf;
27
+ use std::sync::Arc;
28
+ use std::time::Duration;
29
+ use std::time::Instant;
30
+ use tokio::sync::Mutex;
31
+ use tokio::sync::OnceCell;
32
+ use tokio::time::sleep;
33
+ use tokio::time::timeout;
34
+
35
+ pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
36
+ const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5;
37
+ const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
38
+ const CLOUD_CONFIG_BUNDLE_TIMEOUT_RETRY_INTERVAL: Duration = Duration::from_secs(5);
39
+ const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str =
40
+ "Failed to load cloud config bundle (workspace-managed policies).";
41
+ const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!(
42
+ "Your authentication session could not be refreshed automatically. ",
43
+ "Please log out and sign in again."
44
+ );
45
+
46
+ fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
47
+ (auth.get_chatgpt_user_id(), auth.get_account_id())
48
+ }
49
+
50
+ fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
51
+ let Some(plan_type) = auth.account_plan_type() else {
52
+ return false;
53
+ };
54
+ auth.uses_codex_backend()
55
+ && (plan_type.is_business_like()
56
+ || plan_type.is_education_like()
57
+ || plan_type == PlanType::Enterprise)
58
+ }
59
+
60
+ fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
61
+ if bundle.is_empty() {
62
+ None
63
+ } else {
64
+ Some(bundle)
65
+ }
66
+ }
67
+
68
+ enum CachedBundleLookup {
69
+ Hit(Option<CloudConfigBundle>),
70
+ Miss,
71
+ }
72
+
73
+ enum UnauthorizedRecoveryAction {
74
+ RetrySameAttempt,
75
+ RetryNextAttempt,
76
+ }
77
+
78
+ pub(crate) struct CloudConfigBundleService<C> {
79
+ auth_manager: Arc<AuthManager>,
80
+ client: Arc<C>,
81
+ cache: CloudConfigBundleCache,
82
+ cache_enabled: bool,
83
+ codex_home: AbsolutePathBuf,
84
+ timeout: Duration,
85
+ latest_bundle: OnceCell<Mutex<Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>>,
86
+ }
87
+
88
+ impl<C> CloudConfigBundleService<C>
89
+ where
90
+ C: BundleClient + 'static,
91
+ {
92
+ pub(crate) fn new(
93
+ auth_manager: Arc<AuthManager>,
94
+ client: Arc<C>,
95
+ codex_home: PathBuf,
96
+ timeout: Duration,
97
+ ) -> Self {
98
+ let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/");
99
+ Self {
100
+ auth_manager,
101
+ client,
102
+ cache: CloudConfigBundleCache::new(codex_home.clone()),
103
+ cache_enabled: true,
104
+ codex_home,
105
+ timeout,
106
+ latest_bundle: OnceCell::new(),
107
+ }
108
+ }
109
+
110
+ pub(crate) fn without_cache(mut self) -> Self {
111
+ self.cache_enabled = false;
112
+ self
113
+ }
114
+
115
+ pub(crate) async fn get_latest(
116
+ &self,
117
+ ) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
118
+ self.latest_bundle
119
+ .get_or_init(|| async { Mutex::new(self.load_startup_bundle_with_timeout().await) })
120
+ .await
121
+ .lock()
122
+ .await
123
+ .clone()
124
+ }
125
+
126
+ pub(crate) async fn load_startup_bundle_with_timeout(
127
+ &self,
128
+ ) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
129
+ let _timer =
130
+ codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]);
131
+ let started_at = Instant::now();
132
+ let load_result = timeout(self.timeout, self.load_startup_bundle())
133
+ .await
134
+ .inspect_err(|_| {
135
+ let message = format!(
136
+ "Timed out waiting for cloud config bundle after {}s",
137
+ self.timeout.as_secs()
138
+ );
139
+ tracing::error!("{message}");
140
+ emit_load_metric("startup", "error", /*bundle*/ None);
141
+ })
142
+ .map_err(|_| {
143
+ CloudConfigBundleLoadError::new(
144
+ CloudConfigBundleLoadErrorCode::Timeout,
145
+ /*status_code*/ None,
146
+ format!(
147
+ "timed out waiting for cloud config bundle after {}s",
148
+ self.timeout.as_secs()
149
+ ),
150
+ )
151
+ })?;
152
+
153
+ let result = match load_result {
154
+ Ok(result) => result,
155
+ Err(err) => {
156
+ emit_load_metric("startup", "error", /*bundle*/ None);
157
+ return Err(err);
158
+ }
159
+ };
160
+
161
+ match result.as_ref() {
162
+ Some(bundle) => {
163
+ tracing::info!(
164
+ elapsed_ms = started_at.elapsed().as_millis(),
165
+ config_fragments = bundle.config_toml.enterprise_managed.len(),
166
+ requirements_fragments = bundle.requirements_toml.enterprise_managed.len(),
167
+ "Cloud config bundle load completed"
168
+ );
169
+ emit_load_metric("startup", "success", Some(bundle));
170
+ }
171
+ None => {
172
+ tracing::info!(
173
+ elapsed_ms = started_at.elapsed().as_millis(),
174
+ "Cloud config bundle load completed (none)"
175
+ );
176
+ emit_load_metric("startup", "success", /*bundle*/ None);
177
+ }
178
+ }
179
+
180
+ Ok(result)
181
+ }
182
+
183
+ async fn load_startup_bundle(
184
+ &self,
185
+ ) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
186
+ let Some(auth) = self.auth_manager.auth().await else {
187
+ return Ok(None);
188
+ };
189
+ if !cloud_config_eligible_auth(&auth) {
190
+ return Ok(None);
191
+ }
192
+
193
+ if self.cache_enabled {
194
+ // Startup prefers a valid, identity-matched cache entry. The backend is
195
+ // only consulted on cache miss or invalid cache contents.
196
+ let (chatgpt_user_id, account_id) = auth_identity(&auth);
197
+ match self
198
+ .load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref())
199
+ .await
200
+ {
201
+ CachedBundleLookup::Hit(bundle) => return Ok(bundle),
202
+ CachedBundleLookup::Miss => {}
203
+ }
204
+ }
205
+
206
+ self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup")
207
+ .await
208
+ }
209
+
210
+ async fn load_valid_cached_bundle(
211
+ &self,
212
+ chatgpt_user_id: Option<&str>,
213
+ account_id: Option<&str>,
214
+ ) -> CachedBundleLookup {
215
+ match self.cache.load(chatgpt_user_id, account_id).await {
216
+ Ok(signed_payload) => {
217
+ if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) {
218
+ tracing::warn!(
219
+ path = %self.cache.path().display(),
220
+ error = %err,
221
+ "Ignoring invalid cached cloud config bundle"
222
+ );
223
+ self.cache
224
+ .log_load_status(&CacheLoadStatus::CacheInvalidBundle);
225
+ CachedBundleLookup::Miss
226
+ } else {
227
+ tracing::info!(
228
+ path = %self.cache.path().display(),
229
+ "Using cached cloud config bundle"
230
+ );
231
+ CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle))
232
+ }
233
+ }
234
+ Err(cache_load_status) => {
235
+ self.cache.log_load_status(&cache_load_status);
236
+ CachedBundleLookup::Miss
237
+ }
238
+ }
239
+ }
240
+
241
+ async fn fetch_remote_bundle_and_update_cache_with_retries(
242
+ &self,
243
+ mut auth: CodexAuth,
244
+ trigger: &'static str,
245
+ ) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
246
+ let mut attempt = 1;
247
+ let mut last_status_code: Option<u16> = None;
248
+ let mut auth_recovery = self.auth_manager.unauthorized_recovery();
249
+
250
+ while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
251
+ match self.client.get_bundle(&auth).await {
252
+ Ok(bundle) => {
253
+ return self
254
+ .validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle)
255
+ .await;
256
+ }
257
+ Err(BundleRequestError::Retryable(status)) => {
258
+ last_status_code = status.status_code();
259
+ if self
260
+ .retry_after_request_failure(trigger, attempt, status)
261
+ .await
262
+ {
263
+ attempt += 1;
264
+ continue;
265
+ }
266
+ }
267
+ Err(BundleRequestError::Unauthorized {
268
+ status_code,
269
+ message,
270
+ }) => {
271
+ last_status_code = status_code;
272
+ match self
273
+ .handle_unauthorized(
274
+ &mut auth,
275
+ &mut auth_recovery,
276
+ trigger,
277
+ attempt,
278
+ status_code,
279
+ &message,
280
+ )
281
+ .await?
282
+ {
283
+ UnauthorizedRecoveryAction::RetrySameAttempt => continue,
284
+ UnauthorizedRecoveryAction::RetryNextAttempt => {
285
+ attempt += 1;
286
+ continue;
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ break;
293
+ }
294
+
295
+ emit_fetch_final_metric(
296
+ trigger,
297
+ "error",
298
+ "request_retry_exhausted",
299
+ CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
300
+ last_status_code,
301
+ /*bundle*/ None,
302
+ );
303
+ tracing::error!(
304
+ path = %self.cache.path().display(),
305
+ "{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}"
306
+ );
307
+ Err(CloudConfigBundleLoadError::new(
308
+ CloudConfigBundleLoadErrorCode::RequestFailed,
309
+ last_status_code,
310
+ CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE,
311
+ ))
312
+ }
313
+
314
+ async fn validate_and_cache_remote_bundle(
315
+ &self,
316
+ auth: &CodexAuth,
317
+ trigger: &'static str,
318
+ attempt: usize,
319
+ bundle: CloudConfigBundle,
320
+ ) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
321
+ emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None);
322
+ if let Err(err) = validate_bundle(&bundle, &self.codex_home) {
323
+ emit_fetch_final_metric(
324
+ trigger,
325
+ "error",
326
+ "invalid_bundle",
327
+ attempt,
328
+ /*status_code*/ None,
329
+ /*bundle*/ None,
330
+ );
331
+ return Err(err);
332
+ }
333
+
334
+ let (chatgpt_user_id, account_id) = auth_identity(auth);
335
+ if self.cache_enabled
336
+ && let Err(err) = self
337
+ .cache
338
+ .save(chatgpt_user_id, account_id, bundle.clone())
339
+ .await
340
+ {
341
+ tracing::warn!(
342
+ error = %err,
343
+ "Failed to write cloud config bundle cache"
344
+ );
345
+ }
346
+
347
+ emit_fetch_final_metric(
348
+ trigger,
349
+ "success",
350
+ "none",
351
+ attempt,
352
+ /*status_code*/ None,
353
+ Some(&bundle),
354
+ );
355
+ Ok(optional_bundle(bundle))
356
+ }
357
+
358
+ async fn retry_after_request_failure(
359
+ &self,
360
+ trigger: &'static str,
361
+ attempt: usize,
362
+ status: RetryableFailureKind,
363
+ ) -> bool {
364
+ let status_code = status.status_code();
365
+ emit_fetch_attempt_metric(trigger, attempt, "error", status_code);
366
+ if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
367
+ tracing::warn!(
368
+ status = ?status,
369
+ attempt,
370
+ max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
371
+ "Failed to fetch cloud config bundle; retrying"
372
+ );
373
+ sleep(backoff(attempt as u64)).await;
374
+ true
375
+ } else {
376
+ false
377
+ }
378
+ }
379
+
380
+ async fn handle_unauthorized(
381
+ &self,
382
+ auth: &mut CodexAuth,
383
+ auth_recovery: &mut UnauthorizedRecovery,
384
+ trigger: &'static str,
385
+ attempt: usize,
386
+ status_code: Option<u16>,
387
+ message: &str,
388
+ ) -> Result<UnauthorizedRecoveryAction, CloudConfigBundleLoadError> {
389
+ emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code);
390
+ if auth_recovery.has_next() {
391
+ tracing::warn!(
392
+ attempt,
393
+ max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
394
+ "Cloud config bundle request was unauthorized; attempting auth recovery"
395
+ );
396
+ match auth_recovery.next().await {
397
+ Ok(_) => {
398
+ let Some(refreshed_auth) = self.auth_manager.auth().await else {
399
+ tracing::error!(
400
+ "Auth recovery succeeded but no auth is available for cloud config bundle"
401
+ );
402
+ emit_fetch_final_metric(
403
+ trigger,
404
+ "error",
405
+ "auth_recovery_missing_auth",
406
+ attempt,
407
+ status_code,
408
+ /*bundle*/ None,
409
+ );
410
+ return Err(CloudConfigBundleLoadError::new(
411
+ CloudConfigBundleLoadErrorCode::Auth,
412
+ status_code,
413
+ CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
414
+ ));
415
+ };
416
+ *auth = refreshed_auth;
417
+ return Ok(UnauthorizedRecoveryAction::RetrySameAttempt);
418
+ }
419
+ Err(RefreshTokenError::Permanent(failed)) => {
420
+ tracing::warn!(
421
+ error = %failed,
422
+ "Failed to recover from unauthorized cloud config bundle request"
423
+ );
424
+ emit_fetch_final_metric(
425
+ trigger,
426
+ "error",
427
+ "auth_recovery_unrecoverable",
428
+ attempt,
429
+ status_code,
430
+ /*bundle*/ None,
431
+ );
432
+ return Err(CloudConfigBundleLoadError::new(
433
+ CloudConfigBundleLoadErrorCode::Auth,
434
+ status_code,
435
+ failed.message,
436
+ ));
437
+ }
438
+ Err(RefreshTokenError::Transient(recovery_err)) => {
439
+ if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
440
+ tracing::warn!(
441
+ error = %recovery_err,
442
+ attempt,
443
+ max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
444
+ "Failed to recover from unauthorized cloud config bundle request; retrying"
445
+ );
446
+ sleep(backoff(attempt as u64)).await;
447
+ }
448
+ return Ok(UnauthorizedRecoveryAction::RetryNextAttempt);
449
+ }
450
+ }
451
+ }
452
+
453
+ tracing::warn!(
454
+ error = %message,
455
+ "Cloud config bundle request was unauthorized and no auth recovery is available"
456
+ );
457
+ emit_fetch_final_metric(
458
+ trigger,
459
+ "error",
460
+ "auth_recovery_unavailable",
461
+ attempt,
462
+ status_code,
463
+ /*bundle*/ None,
464
+ );
465
+ Err(CloudConfigBundleLoadError::new(
466
+ CloudConfigBundleLoadErrorCode::Auth,
467
+ status_code,
468
+ CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
469
+ ))
470
+ }
471
+
472
+ pub(crate) async fn refresh_cache_in_background(&self) {
473
+ loop {
474
+ let mut refresh_interval = CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL;
475
+ if let Some(latest_bundle) = self.latest_bundle.get()
476
+ && matches!(
477
+ &*latest_bundle.lock().await,
478
+ Err(error) if error.code() == CloudConfigBundleLoadErrorCode::Timeout
479
+ )
480
+ {
481
+ // Recover startup timeouts through this worker without making
482
+ // readers fetch concurrently or extending the startup deadline.
483
+ refresh_interval = CLOUD_CONFIG_BUNDLE_TIMEOUT_RETRY_INTERVAL;
484
+ }
485
+ sleep(refresh_interval).await;
486
+ match timeout(self.timeout, self.refresh_cache_once()).await {
487
+ Ok(true) => {}
488
+ Ok(false) => break,
489
+ Err(_) => {
490
+ tracing::error!(
491
+ "Timed out refreshing cloud config bundle cache from remote; keeping existing cache"
492
+ );
493
+ emit_load_metric("refresh", "error", /*bundle*/ None);
494
+ }
495
+ }
496
+ }
497
+ }
498
+
499
+ async fn refresh_cache_once(&self) -> bool {
500
+ let Some(auth) = self.auth_manager.auth().await else {
501
+ return false;
502
+ };
503
+ if !cloud_config_eligible_auth(&auth) {
504
+ return false;
505
+ }
506
+
507
+ match self
508
+ .fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh")
509
+ .await
510
+ {
511
+ Ok(bundle) => {
512
+ emit_load_metric("refresh", "success", bundle.as_ref());
513
+ if let Some(latest_bundle) = self.latest_bundle.get() {
514
+ *latest_bundle.lock().await = Ok(bundle);
515
+ }
516
+ }
517
+ Err(err) => {
518
+ tracing::error!(
519
+ path = %self.cache.path().display(),
520
+ error = %err,
521
+ "Failed to refresh cloud config bundle cache from remote"
522
+ );
523
+ emit_load_metric("refresh", "error", /*bundle*/ None);
524
+ if let Some(latest_bundle) = self.latest_bundle.get() {
525
+ let mut latest_bundle = latest_bundle.lock().await;
526
+ if latest_bundle.is_err() {
527
+ *latest_bundle = Err(err);
528
+ }
529
+ }
530
+ }
531
+ }
532
+ true
533
+ }
534
+ }
535
+
536
+ #[cfg(test)]
537
+ #[path = "service_tests.rs"]
538
+ mod tests;
codex-rs/cloud-config/src/service_tests.rs ADDED
@@ -0,0 +1,1658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::backend::BundleClient;
3
+ use crate::backend::BundleRequestError;
4
+ use crate::backend::RetryableFailureKind;
5
+ use crate::backend::bundle_from_response;
6
+ use crate::cache::CLOUD_CONFIG_BUNDLE_CACHE_FILENAME;
7
+ use crate::cache::CloudConfigBundleCache;
8
+ use crate::metrics::bundle_shape_tag;
9
+ use base64::Engine;
10
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11
+ use codex_backend_client::ConfigBundleResponse;
12
+ use codex_backend_client::DeliveredTomlFragment;
13
+ use codex_config::AbsolutePathBuf;
14
+ use codex_config::CloudConfigFragment;
15
+ use codex_config::CloudConfigTomlBundle;
16
+ use codex_config::CloudRequirementsFragment;
17
+ use codex_config::CloudRequirementsTomlBundle;
18
+ use codex_config::types::AuthCredentialsStoreMode;
19
+ use codex_core::config::ConfigBuilder;
20
+ use codex_login::AuthKeyringBackendKind;
21
+ use codex_login::auth::AgentIdentityAuth;
22
+ use codex_login::auth::AgentIdentityAuthRecord;
23
+ use codex_login::auth::ExternalAuth;
24
+ use codex_login::auth::ExternalAuthRefreshContext;
25
+ use pretty_assertions::assert_eq;
26
+ use serde_json::json;
27
+ use std::collections::VecDeque;
28
+ use std::future::pending;
29
+ use std::path::Path;
30
+ use std::sync::RwLock;
31
+ use std::sync::atomic::AtomicUsize;
32
+ use std::sync::atomic::Ordering;
33
+ use tempfile::tempdir;
34
+
35
+ fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> {
36
+ std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?;
37
+ Ok(())
38
+ }
39
+
40
+ fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache {
41
+ CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/"))
42
+ }
43
+
44
+ async fn auth_manager_with_api_key() -> Arc<AuthManager> {
45
+ let tmp = tempdir().expect("tempdir");
46
+ let auth_json = json!({
47
+ "OPENAI_API_KEY": "sk-test-key",
48
+ "tokens": null,
49
+ "last_refresh": null,
50
+ });
51
+ write_auth_json(tmp.path(), auth_json).expect("write auth");
52
+ Arc::new(
53
+ AuthManager::new(
54
+ tmp.path().to_path_buf(),
55
+ /*enable_codex_api_key_env*/ false,
56
+ AuthCredentialsStoreMode::File,
57
+ /*forced_chatgpt_workspace_id*/ None,
58
+ /*chatgpt_base_url*/ None,
59
+ AuthKeyringBackendKind::default(),
60
+ codex_login::test_support::transport_default_auth_route_config(),
61
+ )
62
+ .await,
63
+ )
64
+ }
65
+
66
+ async fn auth_manager_with_plan_and_identity(
67
+ plan_type: &str,
68
+ chatgpt_user_id: Option<&str>,
69
+ account_id: Option<&str>,
70
+ ) -> Arc<AuthManager> {
71
+ let tmp = tempdir().expect("tempdir");
72
+ write_auth_json(
73
+ tmp.path(),
74
+ chatgpt_auth_json(
75
+ plan_type,
76
+ chatgpt_user_id,
77
+ account_id,
78
+ "test-access-token",
79
+ "test-refresh-token",
80
+ ),
81
+ )
82
+ .expect("write auth");
83
+ Arc::new(
84
+ AuthManager::new(
85
+ tmp.path().to_path_buf(),
86
+ /*enable_codex_api_key_env*/ false,
87
+ AuthCredentialsStoreMode::File,
88
+ /*forced_chatgpt_workspace_id*/ None,
89
+ /*chatgpt_base_url*/ None,
90
+ AuthKeyringBackendKind::default(),
91
+ codex_login::test_support::transport_default_auth_route_config(),
92
+ )
93
+ .await,
94
+ )
95
+ }
96
+
97
+ async fn auth_manager_with_plan(plan_type: &str) -> Arc<AuthManager> {
98
+ auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")).await
99
+ }
100
+
101
+ async fn auth_manager_with_agent_identity_business_plan() -> Arc<AuthManager> {
102
+ let key_material =
103
+ codex_agent_identity::generate_agent_key_material().expect("generate agent key material");
104
+ AuthManager::from_auth_for_testing(CodexAuth::AgentIdentity(
105
+ AgentIdentityAuth::from_record(
106
+ AgentIdentityAuthRecord {
107
+ agent_runtime_id: "agent-runtime-123".to_string(),
108
+ agent_private_key: key_material.private_key_pkcs8_base64,
109
+ account_id: "account-12345".to_string(),
110
+ chatgpt_user_id: "user-12345".to_string(),
111
+ email: Some("user@example.com".to_string()),
112
+ plan_type: PlanType::Business,
113
+ chatgpt_account_is_fedramp: false,
114
+ task_id: Some("task-123".to_string()),
115
+ },
116
+ "https://auth.openai.com/api/accounts",
117
+ &codex_login::test_support::transport_default_auth_route_config(),
118
+ )
119
+ .await
120
+ .expect("agent identity record should be complete"),
121
+ ))
122
+ }
123
+
124
+ fn chatgpt_auth_json(
125
+ plan_type: &str,
126
+ chatgpt_user_id: Option<&str>,
127
+ account_id: Option<&str>,
128
+ access_token: &str,
129
+ refresh_token: &str,
130
+ ) -> serde_json::Value {
131
+ chatgpt_auth_json_with_last_refresh(
132
+ plan_type,
133
+ chatgpt_user_id,
134
+ account_id,
135
+ access_token,
136
+ refresh_token,
137
+ "2025-01-01T00:00:00Z",
138
+ )
139
+ }
140
+
141
+ fn chatgpt_auth_json_with_last_refresh(
142
+ plan_type: &str,
143
+ chatgpt_user_id: Option<&str>,
144
+ account_id: Option<&str>,
145
+ access_token: &str,
146
+ refresh_token: &str,
147
+ last_refresh: &str,
148
+ ) -> serde_json::Value {
149
+ let fake_jwt = fake_chatgpt_jwt(plan_type, chatgpt_user_id, b"sig");
150
+ json!({
151
+ "OPENAI_API_KEY": null,
152
+ "tokens": {
153
+ "id_token": fake_jwt,
154
+ "access_token": access_token,
155
+ "refresh_token": refresh_token,
156
+ "account_id": account_id,
157
+ },
158
+ "last_refresh": last_refresh,
159
+ })
160
+ }
161
+
162
+ fn fake_chatgpt_jwt(plan_type: &str, chatgpt_user_id: Option<&str>, signature: &[u8]) -> String {
163
+ let header = json!({ "alg": "none", "typ": "JWT" });
164
+ let auth_payload = json!({
165
+ "chatgpt_plan_type": plan_type,
166
+ "chatgpt_user_id": chatgpt_user_id,
167
+ "user_id": chatgpt_user_id,
168
+ });
169
+ let payload = json!({
170
+ "email": "user@example.com",
171
+ "https://api.openai.com/auth": auth_payload,
172
+ });
173
+ let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header"));
174
+ let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload"));
175
+ let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
176
+ format!("{header_b64}.{payload_b64}.{signature_b64}")
177
+ }
178
+
179
+ fn test_bundle() -> CloudConfigBundle {
180
+ CloudConfigBundle {
181
+ config_toml: CloudConfigTomlBundle {
182
+ enterprise_managed: vec![test_config_fragment()],
183
+ },
184
+ requirements_toml: CloudRequirementsTomlBundle {
185
+ enterprise_managed: vec![test_requirements_fragment()],
186
+ },
187
+ }
188
+ }
189
+
190
+ fn test_config_fragment() -> CloudConfigFragment {
191
+ CloudConfigFragment {
192
+ id: "cfg_1".to_string(),
193
+ name: "Base config".to_string(),
194
+ contents: "model = \"gpt-5\"".to_string(),
195
+ }
196
+ }
197
+
198
+ fn test_requirements_fragment() -> CloudRequirementsFragment {
199
+ CloudRequirementsFragment {
200
+ id: "req_1".to_string(),
201
+ name: "Base requirements".to_string(),
202
+ contents: "allowed_approval_policies = [\"never\"]".to_string(),
203
+ }
204
+ }
205
+
206
+ fn invalid_config_bundle() -> CloudConfigBundle {
207
+ CloudConfigBundle {
208
+ config_toml: CloudConfigTomlBundle {
209
+ enterprise_managed: vec![CloudConfigFragment {
210
+ id: "cfg_invalid".to_string(),
211
+ name: "Invalid config".to_string(),
212
+ contents: "model = [".to_string(),
213
+ }],
214
+ },
215
+ requirements_toml: CloudRequirementsTomlBundle::default(),
216
+ }
217
+ }
218
+
219
+ fn request_error() -> BundleRequestError {
220
+ BundleRequestError::Retryable(RetryableFailureKind::Request { status_code: None })
221
+ }
222
+
223
+ struct StaticBundleClient {
224
+ bundle: CloudConfigBundle,
225
+ request_count: AtomicUsize,
226
+ }
227
+
228
+ impl StaticBundleClient {
229
+ fn new(bundle: CloudConfigBundle) -> Self {
230
+ Self {
231
+ bundle,
232
+ request_count: AtomicUsize::new(0),
233
+ }
234
+ }
235
+ }
236
+
237
+ impl BundleClient for StaticBundleClient {
238
+ async fn get_bundle(&self, _auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
239
+ self.request_count.fetch_add(1, Ordering::SeqCst);
240
+ Ok(self.bundle.clone())
241
+ }
242
+ }
243
+
244
+ struct PendingBundleClient;
245
+
246
+ impl BundleClient for PendingBundleClient {
247
+ async fn get_bundle(&self, _auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
248
+ pending::<()>().await;
249
+ Ok(CloudConfigBundle::default())
250
+ }
251
+ }
252
+
253
+ struct NotifyingPendingBundleClient {
254
+ request_started: Arc<tokio::sync::Notify>,
255
+ request_cancelled: Arc<tokio::sync::Notify>,
256
+ }
257
+
258
+ impl Drop for NotifyingPendingBundleClient {
259
+ fn drop(&mut self) {
260
+ self.request_cancelled.notify_one();
261
+ }
262
+ }
263
+
264
+ impl BundleClient for NotifyingPendingBundleClient {
265
+ async fn get_bundle(&self, _auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
266
+ self.request_started.notify_one();
267
+ pending::<()>().await;
268
+ Ok(CloudConfigBundle::default())
269
+ }
270
+ }
271
+
272
+ struct SequenceBundleClient {
273
+ responses: tokio::sync::Mutex<VecDeque<Result<CloudConfigBundle, BundleRequestError>>>,
274
+ request_count: AtomicUsize,
275
+ request_started: tokio::sync::Notify,
276
+ timeout_attempts: usize,
277
+ }
278
+
279
+ impl SequenceBundleClient {
280
+ fn new(responses: Vec<Result<CloudConfigBundle, BundleRequestError>>) -> Self {
281
+ Self {
282
+ responses: tokio::sync::Mutex::new(VecDeque::from(responses)),
283
+ request_count: AtomicUsize::new(0),
284
+ request_started: tokio::sync::Notify::new(),
285
+ timeout_attempts: 0,
286
+ }
287
+ }
288
+ }
289
+
290
+ impl BundleClient for SequenceBundleClient {
291
+ async fn get_bundle(&self, _auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
292
+ let attempt = self.request_count.fetch_add(1, Ordering::SeqCst);
293
+ self.request_started.notify_one();
294
+ if attempt < self.timeout_attempts {
295
+ pending::<()>().await;
296
+ }
297
+ let mut responses = self.responses.lock().await;
298
+ responses
299
+ .pop_front()
300
+ .unwrap_or_else(|| Ok(CloudConfigBundle::default()))
301
+ }
302
+ }
303
+
304
+ struct TokenBundleClient {
305
+ expected_token: String,
306
+ bundle: CloudConfigBundle,
307
+ request_count: AtomicUsize,
308
+ }
309
+
310
+ impl BundleClient for TokenBundleClient {
311
+ async fn get_bundle(&self, auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
312
+ self.request_count.fetch_add(1, Ordering::SeqCst);
313
+ if matches!(
314
+ auth.get_token().as_deref(),
315
+ Ok(token) if token == self.expected_token.as_str()
316
+ ) {
317
+ Ok(self.bundle.clone())
318
+ } else {
319
+ Err(BundleRequestError::Unauthorized {
320
+ status_code: Some(401),
321
+ message: "GET /config/bundle failed: 401".to_string(),
322
+ })
323
+ }
324
+ }
325
+ }
326
+
327
+ struct UnauthorizedBundleClient {
328
+ message: String,
329
+ request_count: AtomicUsize,
330
+ }
331
+
332
+ struct TestExternalChatgptAuth {
333
+ current: RwLock<CodexAuth>,
334
+ refreshed: CodexAuth,
335
+ refresh_count: AtomicUsize,
336
+ }
337
+
338
+ impl ExternalAuth for TestExternalChatgptAuth {
339
+ fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
340
+ Box::pin(async {
341
+ self.current
342
+ .read()
343
+ .map(|auth| auth.clone())
344
+ .map_err(|_| std::io::Error::other("external auth lock is poisoned"))
345
+ })
346
+ }
347
+
348
+ fn refresh(
349
+ &self,
350
+ _context: ExternalAuthRefreshContext,
351
+ ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
352
+ Box::pin(async {
353
+ let refreshed = self.refreshed.clone();
354
+ *self
355
+ .current
356
+ .write()
357
+ .map_err(|_| std::io::Error::other("external auth lock is poisoned"))? =
358
+ refreshed.clone();
359
+ self.refresh_count.fetch_add(1, Ordering::SeqCst);
360
+ Ok(refreshed)
361
+ })
362
+ }
363
+ }
364
+
365
+ impl BundleClient for UnauthorizedBundleClient {
366
+ async fn get_bundle(&self, _auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
367
+ self.request_count.fetch_add(1, Ordering::SeqCst);
368
+ Err(BundleRequestError::Unauthorized {
369
+ status_code: Some(401),
370
+ message: self.message.clone(),
371
+ })
372
+ }
373
+ }
374
+
375
+ #[test]
376
+ fn bundle_shape_tag_describes_sorted_enterprise_sources() {
377
+ assert_eq!(bundle_shape_tag(/*bundle*/ None), "none");
378
+ assert_eq!(
379
+ bundle_shape_tag(Some(&CloudConfigBundle::default())),
380
+ "empty"
381
+ );
382
+ assert_eq!(
383
+ bundle_shape_tag(Some(&CloudConfigBundle {
384
+ config_toml: CloudConfigTomlBundle {
385
+ enterprise_managed: vec![test_config_fragment()],
386
+ },
387
+ requirements_toml: CloudRequirementsTomlBundle::default(),
388
+ })),
389
+ "enterprise_config"
390
+ );
391
+ assert_eq!(
392
+ bundle_shape_tag(Some(&CloudConfigBundle {
393
+ config_toml: CloudConfigTomlBundle::default(),
394
+ requirements_toml: CloudRequirementsTomlBundle {
395
+ enterprise_managed: vec![test_requirements_fragment()],
396
+ },
397
+ })),
398
+ "enterprise_requirements"
399
+ );
400
+ assert_eq!(
401
+ bundle_shape_tag(Some(&CloudConfigBundle {
402
+ config_toml: CloudConfigTomlBundle {
403
+ enterprise_managed: vec![test_config_fragment()],
404
+ },
405
+ requirements_toml: CloudRequirementsTomlBundle {
406
+ enterprise_managed: vec![test_requirements_fragment()],
407
+ },
408
+ })),
409
+ "enterprise_config,enterprise_requirements"
410
+ );
411
+ }
412
+
413
+ #[tokio::test]
414
+ async fn get_bundle_skips_non_chatgpt_auth() {
415
+ let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
416
+ let codex_home = tempdir().expect("tempdir");
417
+ let service = CloudConfigBundleService::new(
418
+ auth_manager_with_api_key().await,
419
+ fetcher.clone(),
420
+ codex_home.path().to_path_buf(),
421
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
422
+ );
423
+
424
+ assert_eq!(service.load_startup_bundle().await, Ok(None));
425
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
426
+ }
427
+
428
+ #[tokio::test]
429
+ async fn get_bundle_skips_individual_plan() {
430
+ let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
431
+ let codex_home = tempdir().expect("tempdir");
432
+ let service = CloudConfigBundleService::new(
433
+ auth_manager_with_plan("pro").await,
434
+ fetcher.clone(),
435
+ codex_home.path().to_path_buf(),
436
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
437
+ );
438
+
439
+ assert_eq!(service.load_startup_bundle().await, Ok(None));
440
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
441
+ }
442
+
443
+ #[tokio::test]
444
+ async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() {
445
+ for plan_type in [
446
+ "business",
447
+ "ent26",
448
+ "enterprise_cbp_automation",
449
+ "enterprise_cbp_usage_based",
450
+ "enterprise",
451
+ "hc",
452
+ "edu",
453
+ "education",
454
+ "edu_plus",
455
+ "edu_pro",
456
+ ] {
457
+ let bundle = test_bundle();
458
+ let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
459
+ let codex_home = tempdir().expect("tempdir");
460
+ let service = CloudConfigBundleService::new(
461
+ auth_manager_with_plan(plan_type).await,
462
+ fetcher.clone(),
463
+ codex_home.path().to_path_buf(),
464
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
465
+ );
466
+
467
+ assert_eq!(
468
+ service.load_startup_bundle().await,
469
+ Ok(Some(bundle)),
470
+ "plan_type: {plan_type}"
471
+ );
472
+ assert_eq!(
473
+ fetcher.request_count.load(Ordering::SeqCst),
474
+ 1,
475
+ "plan_type: {plan_type}"
476
+ );
477
+ assert!(
478
+ codex_home
479
+ .path()
480
+ .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME)
481
+ .exists(),
482
+ "plan_type: {plan_type}"
483
+ );
484
+ }
485
+ }
486
+
487
+ #[tokio::test]
488
+ async fn get_bundle_allows_agent_identity_business_plan() {
489
+ let bundle = test_bundle();
490
+ let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
491
+ let codex_home = tempdir().expect("tempdir");
492
+ let service = CloudConfigBundleService::new(
493
+ auth_manager_with_agent_identity_business_plan().await,
494
+ fetcher.clone(),
495
+ codex_home.path().to_path_buf(),
496
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
497
+ );
498
+
499
+ assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle)));
500
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
501
+ assert!(
502
+ codex_home
503
+ .path()
504
+ .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME)
505
+ .exists()
506
+ );
507
+ }
508
+
509
+ #[tokio::test]
510
+ async fn get_bundle_skips_team_like_business_plans() {
511
+ for plan_type in [
512
+ "self_serve_business_prolite",
513
+ "self_serve_business_usage_based",
514
+ ] {
515
+ let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
516
+ let codex_home = tempdir().expect("tempdir");
517
+ let service = CloudConfigBundleService::new(
518
+ auth_manager_with_plan(plan_type).await,
519
+ fetcher.clone(),
520
+ codex_home.path().to_path_buf(),
521
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
522
+ );
523
+
524
+ assert_eq!(service.load_startup_bundle().await, Ok(None));
525
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
526
+ }
527
+ }
528
+
529
+ #[tokio::test]
530
+ async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() {
531
+ let codex_home = tempdir().expect("tempdir");
532
+ let fetcher = Arc::new(StaticBundleClient::new(invalid_config_bundle()));
533
+ let service = CloudConfigBundleService::new(
534
+ auth_manager_with_plan("business").await,
535
+ fetcher.clone(),
536
+ codex_home.path().to_path_buf(),
537
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
538
+ );
539
+
540
+ let err = service
541
+ .load_startup_bundle()
542
+ .await
543
+ .expect_err("invalid remote bundle should fail closed");
544
+
545
+ assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle);
546
+ assert!(err.to_string().contains("invalid cloud config bundle"));
547
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
548
+ assert!(
549
+ !codex_home
550
+ .path()
551
+ .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME)
552
+ .exists()
553
+ );
554
+ }
555
+
556
+ #[tokio::test]
557
+ async fn invalid_cloud_provider_does_not_replace_cached_bundle() {
558
+ let codex_home = tempdir().expect("tempdir");
559
+ let cache = create_test_cache(codex_home.path());
560
+ let previous = test_bundle();
561
+ cache
562
+ .save(
563
+ Some("user-12345".to_string()),
564
+ Some("account-12345".to_string()),
565
+ previous.clone(),
566
+ )
567
+ .await
568
+ .expect("cache valid bundle");
569
+
570
+ for contents in [
571
+ "[model_providers.openai]\nname = 'Reserved'",
572
+ "[model_providers.gateway]\nname = ' '",
573
+ "[model_providers.gateway]\nbase_url = 'https://gateway.example/v1'",
574
+ "[model_providers.amazon-bedrock]\nname = 'Managed Bedrock'",
575
+ "[model_providers.amazon-bedrock-runtime]\nname = 'Managed Bedrock Runtime'",
576
+ "[model_providers.amazon-bedrock]\nrequest_max_retries = 3",
577
+ "[model_providers.gateway]\nname = 'Gateway'\n[model_providers.gateway.auth]\ntimeout_ms = 10000",
578
+ ] {
579
+ let mut invalid = test_bundle();
580
+ invalid.requirements_toml.enterprise_managed[0].contents = contents.to_string();
581
+ let auth_manager = auth_manager_with_plan("business").await;
582
+ let auth = auth_manager.auth().await.expect("business auth");
583
+ let service = CloudConfigBundleService::new(
584
+ auth_manager,
585
+ Arc::new(StaticBundleClient::new(invalid.clone())),
586
+ codex_home.path().to_path_buf(),
587
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
588
+ );
589
+ let error = service
590
+ .validate_and_cache_remote_bundle(&auth, "refresh", /*attempt*/ 1, invalid)
591
+ .await
592
+ .expect_err("invalid provider must fail before cache write");
593
+ assert_eq!(error.code(), CloudConfigBundleLoadErrorCode::InvalidBundle);
594
+ assert_eq!(
595
+ cache
596
+ .load(Some("user-12345"), Some("account-12345"))
597
+ .await
598
+ .expect("retain previous cache")
599
+ .bundle,
600
+ previous
601
+ );
602
+ }
603
+ }
604
+
605
+ #[tokio::test]
606
+ async fn get_bundle_ignores_invalid_cache_and_refetches() {
607
+ let codex_home = tempdir().expect("tempdir");
608
+ let cache = create_test_cache(codex_home.path());
609
+ cache
610
+ .save(
611
+ Some("user-12345".to_string()),
612
+ Some("account-12345".to_string()),
613
+ invalid_config_bundle(),
614
+ )
615
+ .await
616
+ .expect("write invalid cache");
617
+ let replacement_bundle = test_bundle();
618
+ let fetcher = Arc::new(StaticBundleClient::new(replacement_bundle.clone()));
619
+ let service = CloudConfigBundleService::new(
620
+ auth_manager_with_plan("business").await,
621
+ fetcher.clone(),
622
+ codex_home.path().to_path_buf(),
623
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
624
+ );
625
+
626
+ assert_eq!(
627
+ service.load_startup_bundle().await,
628
+ Ok(Some(replacement_bundle.clone()))
629
+ );
630
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
631
+ assert_eq!(
632
+ cache
633
+ .load(Some("user-12345"), Some("account-12345"))
634
+ .await
635
+ .expect("load refreshed cache")
636
+ .bundle,
637
+ replacement_bundle
638
+ );
639
+ }
640
+
641
+ #[tokio::test]
642
+ async fn get_bundle_empty_response_is_success_and_cached() {
643
+ let codex_home = tempdir().expect("tempdir");
644
+ let fetcher = Arc::new(StaticBundleClient::new(CloudConfigBundle::default()));
645
+ let service = CloudConfigBundleService::new(
646
+ auth_manager_with_plan("enterprise").await,
647
+ fetcher.clone(),
648
+ codex_home.path().to_path_buf(),
649
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
650
+ );
651
+
652
+ assert_eq!(service.load_startup_bundle().await, Ok(None));
653
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
654
+ assert!(
655
+ codex_home
656
+ .path()
657
+ .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME)
658
+ .exists()
659
+ );
660
+ }
661
+
662
+ #[tokio::test]
663
+ async fn get_bundle_uses_cache_when_valid() {
664
+ let bundle = test_bundle();
665
+ let codex_home = tempdir().expect("tempdir");
666
+ let prime_service = CloudConfigBundleService::new(
667
+ auth_manager_with_plan("business").await,
668
+ Arc::new(StaticBundleClient::new(bundle.clone())),
669
+ codex_home.path().to_path_buf(),
670
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
671
+ );
672
+ let _ = prime_service.load_startup_bundle().await;
673
+
674
+ let fetcher = Arc::new(SequenceBundleClient::new(vec![Err(request_error())]));
675
+ let service = CloudConfigBundleService::new(
676
+ auth_manager_with_plan("business").await,
677
+ fetcher.clone(),
678
+ codex_home.path().to_path_buf(),
679
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
680
+ );
681
+
682
+ assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle)));
683
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
684
+ }
685
+
686
+ #[tokio::test]
687
+ async fn get_bundle_without_cache_ignores_and_preserves_valid_cache() {
688
+ let codex_home = tempdir().expect("tempdir");
689
+ let cache = create_test_cache(codex_home.path());
690
+ cache
691
+ .save(
692
+ Some("user-12345".to_string()),
693
+ Some("account-12345".to_string()),
694
+ CloudConfigBundle::default(),
695
+ )
696
+ .await
697
+ .expect("write empty cache");
698
+ let cached_bytes = std::fs::read(cache.path()).expect("read cache");
699
+ let bundle = test_bundle();
700
+ let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
701
+ let service = CloudConfigBundleService::new(
702
+ auth_manager_with_plan("business").await,
703
+ fetcher.clone(),
704
+ codex_home.path().to_path_buf(),
705
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
706
+ )
707
+ .without_cache();
708
+
709
+ assert_eq!(
710
+ service.load_startup_bundle_with_timeout().await,
711
+ Ok(Some(bundle))
712
+ );
713
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
714
+ assert_eq!(
715
+ std::fs::read(cache.path()).expect("read unchanged cache"),
716
+ cached_bytes
717
+ );
718
+ }
719
+
720
+ #[tokio::test(start_paused = true)]
721
+ async fn get_bundle_without_cache_fails_closed_on_request_failure() {
722
+ let codex_home = tempdir().expect("tempdir");
723
+ create_test_cache(codex_home.path())
724
+ .save(
725
+ Some("user-12345".to_string()),
726
+ Some("account-12345".to_string()),
727
+ test_bundle(),
728
+ )
729
+ .await
730
+ .expect("write valid cache");
731
+ let fetcher = Arc::new(SequenceBundleClient::new(vec![
732
+ Err(request_error());
733
+ CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS
734
+ ]));
735
+ let service = CloudConfigBundleService::new(
736
+ auth_manager_with_plan("business").await,
737
+ fetcher.clone(),
738
+ codex_home.path().to_path_buf(),
739
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
740
+ )
741
+ .without_cache();
742
+
743
+ let err = service
744
+ .load_startup_bundle_with_timeout()
745
+ .await
746
+ .expect_err("request failure must not fall back to the cache");
747
+ assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed);
748
+ assert_eq!(
749
+ fetcher.request_count.load(Ordering::SeqCst),
750
+ CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS
751
+ );
752
+ }
753
+
754
+ #[tokio::test]
755
+ async fn get_bundle_ignores_cache_for_different_auth_identity() {
756
+ let codex_home = tempdir().expect("tempdir");
757
+ let prime_service = CloudConfigBundleService::new(
758
+ auth_manager_with_plan_and_identity("business", Some("user-12345"), Some("account-12345"))
759
+ .await,
760
+ Arc::new(StaticBundleClient::new(test_bundle())),
761
+ codex_home.path().to_path_buf(),
762
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
763
+ );
764
+ let _ = prime_service.load_startup_bundle().await;
765
+
766
+ let replacement_bundle = CloudConfigBundle {
767
+ config_toml: CloudConfigTomlBundle::default(),
768
+ requirements_toml: CloudRequirementsTomlBundle {
769
+ enterprise_managed: vec![CloudRequirementsFragment {
770
+ id: "req_2".to_string(),
771
+ name: "Replacement requirements".to_string(),
772
+ contents: "allowed_approval_policies = [\"on-request\"]".to_string(),
773
+ }],
774
+ },
775
+ };
776
+ let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok(
777
+ replacement_bundle.clone()
778
+ )]));
779
+ let service = CloudConfigBundleService::new(
780
+ auth_manager_with_plan_and_identity("business", Some("user-99999"), Some("account-12345"))
781
+ .await,
782
+ fetcher.clone(),
783
+ codex_home.path().to_path_buf(),
784
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
785
+ );
786
+
787
+ assert_eq!(
788
+ service.load_startup_bundle().await,
789
+ Ok(Some(replacement_bundle))
790
+ );
791
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
792
+ }
793
+
794
+ #[tokio::test(start_paused = true)]
795
+ async fn get_bundle_times_out() {
796
+ let codex_home = tempdir().expect("tempdir");
797
+ let service = CloudConfigBundleService::new(
798
+ auth_manager_with_plan("enterprise").await,
799
+ Arc::new(PendingBundleClient),
800
+ codex_home.path().to_path_buf(),
801
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
802
+ );
803
+ let handle = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await });
804
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await;
805
+
806
+ let result = handle.await.expect("cloud config bundle task");
807
+ let err = result.expect_err("cloud config bundle timeout should fail closed");
808
+ assert!(
809
+ err.to_string()
810
+ .contains("timed out waiting for cloud config bundle")
811
+ );
812
+ }
813
+
814
+ #[tokio::test(start_paused = true)]
815
+ async fn get_bundle_retries_until_success() {
816
+ let fetcher = Arc::new(SequenceBundleClient::new(vec![
817
+ Err(request_error()),
818
+ Ok(test_bundle()),
819
+ ]));
820
+ let codex_home = tempdir().expect("tempdir");
821
+ let service = CloudConfigBundleService::new(
822
+ auth_manager_with_plan("business").await,
823
+ fetcher.clone(),
824
+ codex_home.path().to_path_buf(),
825
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
826
+ );
827
+
828
+ let handle = tokio::spawn(async move { service.load_startup_bundle().await });
829
+ tokio::task::yield_now().await;
830
+ tokio::time::advance(Duration::from_secs(1)).await;
831
+
832
+ assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle())));
833
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
834
+ }
835
+
836
+ #[tokio::test]
837
+ async fn get_bundle_recovers_after_unauthorized_reload() {
838
+ let auth_home = tempdir().expect("tempdir");
839
+ write_auth_json(
840
+ auth_home.path(),
841
+ chatgpt_auth_json_with_last_refresh(
842
+ "business",
843
+ Some("user-12345"),
844
+ Some("account-12345"),
845
+ "stale-access-token",
846
+ "test-refresh-token",
847
+ // Keep auth "fresh" so the first request hits unauthorized recovery
848
+ // instead of AuthManager::auth() proactively reloading from disk.
849
+ "3025-01-01T00:00:00Z",
850
+ ),
851
+ )
852
+ .expect("write initial auth");
853
+ let auth_manager = Arc::new(
854
+ AuthManager::new(
855
+ auth_home.path().to_path_buf(),
856
+ /*enable_codex_api_key_env*/ false,
857
+ AuthCredentialsStoreMode::File,
858
+ /*forced_chatgpt_workspace_id*/ None,
859
+ /*chatgpt_base_url*/ None,
860
+ AuthKeyringBackendKind::default(),
861
+ codex_login::test_support::transport_default_auth_route_config(),
862
+ )
863
+ .await,
864
+ );
865
+
866
+ write_auth_json(
867
+ auth_home.path(),
868
+ chatgpt_auth_json_with_last_refresh(
869
+ "business",
870
+ Some("user-12345"),
871
+ Some("account-12345"),
872
+ "fresh-access-token",
873
+ "test-refresh-token",
874
+ "3025-01-01T00:00:00Z",
875
+ ),
876
+ )
877
+ .expect("write refreshed auth");
878
+ let fetcher = Arc::new(TokenBundleClient {
879
+ expected_token: "fresh-access-token".to_string(),
880
+ bundle: test_bundle(),
881
+ request_count: AtomicUsize::new(0),
882
+ });
883
+ let codex_home = tempdir().expect("tempdir");
884
+ let service = CloudConfigBundleService::new(
885
+ auth_manager,
886
+ fetcher.clone(),
887
+ codex_home.path().to_path_buf(),
888
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
889
+ );
890
+
891
+ assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle())));
892
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
893
+ }
894
+
895
+ #[tokio::test]
896
+ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() {
897
+ let auth_home = tempdir().expect("tempdir");
898
+ write_auth_json(
899
+ auth_home.path(),
900
+ chatgpt_auth_json_with_last_refresh(
901
+ "business",
902
+ Some("user-12345"),
903
+ Some("account-12345"),
904
+ "stale-access-token",
905
+ "test-refresh-token",
906
+ "3025-01-01T00:00:00Z",
907
+ ),
908
+ )
909
+ .expect("write initial auth");
910
+ let auth_manager = Arc::new(
911
+ AuthManager::new(
912
+ auth_home.path().to_path_buf(),
913
+ /*enable_codex_api_key_env*/ false,
914
+ AuthCredentialsStoreMode::File,
915
+ /*forced_chatgpt_workspace_id*/ None,
916
+ /*chatgpt_base_url*/ None,
917
+ AuthKeyringBackendKind::default(),
918
+ codex_login::test_support::transport_default_auth_route_config(),
919
+ )
920
+ .await,
921
+ );
922
+
923
+ write_auth_json(
924
+ auth_home.path(),
925
+ chatgpt_auth_json_with_last_refresh(
926
+ "business",
927
+ Some("user-99999"),
928
+ Some("account-12345"),
929
+ "fresh-access-token",
930
+ "test-refresh-token",
931
+ "3025-01-01T00:00:00Z",
932
+ ),
933
+ )
934
+ .expect("write refreshed auth");
935
+ let fetcher = Arc::new(TokenBundleClient {
936
+ expected_token: "fresh-access-token".to_string(),
937
+ bundle: test_bundle(),
938
+ request_count: AtomicUsize::new(0),
939
+ });
940
+ let codex_home = tempdir().expect("tempdir");
941
+ let service = CloudConfigBundleService::new(
942
+ auth_manager,
943
+ fetcher.clone(),
944
+ codex_home.path().to_path_buf(),
945
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
946
+ );
947
+
948
+ assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle())));
949
+ let cache = create_test_cache(codex_home.path());
950
+ assert_eq!(
951
+ cache
952
+ .load(Some("user-99999"), Some("account-12345"))
953
+ .await
954
+ .expect("load cache")
955
+ .bundle,
956
+ test_bundle()
957
+ );
958
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
959
+ }
960
+
961
+ #[tokio::test]
962
+ async fn get_bundle_surfaces_auth_recovery_message() {
963
+ let auth_home = tempdir().expect("tempdir");
964
+ write_auth_json(
965
+ auth_home.path(),
966
+ chatgpt_auth_json(
967
+ "enterprise",
968
+ Some("user-12345"),
969
+ Some("account-12345"),
970
+ "stale-access-token",
971
+ "test-refresh-token",
972
+ ),
973
+ )
974
+ .expect("write auth");
975
+ let auth_manager = Arc::new(
976
+ AuthManager::new(
977
+ auth_home.path().to_path_buf(),
978
+ /*enable_codex_api_key_env*/ false,
979
+ AuthCredentialsStoreMode::File,
980
+ /*forced_chatgpt_workspace_id*/ None,
981
+ /*chatgpt_base_url*/ None,
982
+ AuthKeyringBackendKind::default(),
983
+ codex_login::test_support::transport_default_auth_route_config(),
984
+ )
985
+ .await,
986
+ );
987
+
988
+ write_auth_json(
989
+ auth_home.path(),
990
+ chatgpt_auth_json(
991
+ "enterprise",
992
+ Some("user-12345"),
993
+ Some("account-99999"),
994
+ "fresh-access-token",
995
+ "test-refresh-token",
996
+ ),
997
+ )
998
+ .expect("write mismatched auth");
999
+ let fetcher = Arc::new(UnauthorizedBundleClient {
1000
+ message: "GET /config/bundle failed: 401".to_string(),
1001
+ request_count: AtomicUsize::new(0),
1002
+ });
1003
+ let codex_home = tempdir().expect("tempdir");
1004
+ let service = CloudConfigBundleService::new(
1005
+ auth_manager,
1006
+ fetcher.clone(),
1007
+ codex_home.path().to_path_buf(),
1008
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1009
+ );
1010
+
1011
+ let err = service
1012
+ .load_startup_bundle()
1013
+ .await
1014
+ .expect_err("cloud config bundle should surface auth recovery errors");
1015
+ assert_eq!(
1016
+ err,
1017
+ CloudConfigBundleLoadError::new(
1018
+ CloudConfigBundleLoadErrorCode::Auth,
1019
+ Some(401),
1020
+ "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
1021
+ )
1022
+ );
1023
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
1024
+ }
1025
+
1026
+ #[tokio::test]
1027
+ async fn get_bundle_refreshes_external_auth_after_unauthorized() {
1028
+ let auth_home = tempdir().expect("tempdir");
1029
+ let auth_manager = Arc::new(
1030
+ AuthManager::new(
1031
+ auth_home.path().to_path_buf(),
1032
+ /*enable_codex_api_key_env*/ false,
1033
+ AuthCredentialsStoreMode::Ephemeral,
1034
+ /*forced_chatgpt_workspace_id*/ None,
1035
+ /*chatgpt_base_url*/ None,
1036
+ AuthKeyringBackendKind::default(),
1037
+ codex_login::test_support::transport_default_auth_route_config(),
1038
+ )
1039
+ .await,
1040
+ );
1041
+ let initial_auth = CodexAuth::from_external_chatgpt_tokens(
1042
+ &fake_chatgpt_jwt("enterprise", Some("user-12345"), b"initial"),
1043
+ "account-12345",
1044
+ Some("enterprise"),
1045
+ )
1046
+ .expect("initial external auth");
1047
+ let refreshed_token = fake_chatgpt_jwt("enterprise", Some("user-12345"), b"refreshed");
1048
+ let refreshed_auth = CodexAuth::from_external_chatgpt_tokens(
1049
+ &refreshed_token,
1050
+ "account-12345",
1051
+ Some("enterprise"),
1052
+ )
1053
+ .expect("refreshed external auth");
1054
+ let external_auth = Arc::new(TestExternalChatgptAuth {
1055
+ current: RwLock::new(initial_auth),
1056
+ refreshed: refreshed_auth,
1057
+ refresh_count: AtomicUsize::new(0),
1058
+ });
1059
+ auth_manager
1060
+ .set_external_auth(external_auth.clone())
1061
+ .await
1062
+ .expect("set external auth");
1063
+
1064
+ let fetcher = Arc::new(TokenBundleClient {
1065
+ expected_token: refreshed_token,
1066
+ bundle: test_bundle(),
1067
+ request_count: AtomicUsize::new(0),
1068
+ });
1069
+ let codex_home = tempdir().expect("tempdir");
1070
+ let service = CloudConfigBundleService::new(
1071
+ auth_manager,
1072
+ fetcher.clone(),
1073
+ codex_home.path().to_path_buf(),
1074
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1075
+ );
1076
+
1077
+ assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle())));
1078
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
1079
+ assert_eq!(external_auth.refresh_count.load(Ordering::SeqCst), 1);
1080
+ }
1081
+
1082
+ #[tokio::test]
1083
+ async fn get_bundle_does_not_use_cache_when_auth_identity_is_incomplete() {
1084
+ let codex_home = tempdir().expect("tempdir");
1085
+ let prime_service = CloudConfigBundleService::new(
1086
+ auth_manager_with_plan("business").await,
1087
+ Arc::new(StaticBundleClient::new(test_bundle())),
1088
+ codex_home.path().to_path_buf(),
1089
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1090
+ );
1091
+ let _ = prime_service.load_startup_bundle().await;
1092
+
1093
+ let replacement_bundle = CloudConfigBundle {
1094
+ config_toml: CloudConfigTomlBundle::default(),
1095
+ requirements_toml: CloudRequirementsTomlBundle {
1096
+ enterprise_managed: vec![CloudRequirementsFragment {
1097
+ id: "req_2".to_string(),
1098
+ name: "Replacement requirements".to_string(),
1099
+ contents: "allowed_approval_policies = [\"on-request\"]".to_string(),
1100
+ }],
1101
+ },
1102
+ };
1103
+ let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok(
1104
+ replacement_bundle.clone()
1105
+ )]));
1106
+ let service = CloudConfigBundleService::new(
1107
+ auth_manager_with_plan_and_identity(
1108
+ "business",
1109
+ /*chatgpt_user_id*/ None,
1110
+ Some("account-12345"),
1111
+ )
1112
+ .await,
1113
+ fetcher.clone(),
1114
+ codex_home.path().to_path_buf(),
1115
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1116
+ );
1117
+
1118
+ assert_eq!(
1119
+ service.load_startup_bundle().await,
1120
+ Ok(Some(replacement_bundle))
1121
+ );
1122
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
1123
+ }
1124
+
1125
+ #[tokio::test(start_paused = true)]
1126
+ async fn get_bundle_stops_after_max_retries() {
1127
+ let fetcher = Arc::new(SequenceBundleClient::new(vec![
1128
+ Err(request_error());
1129
+ CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS
1130
+ ]));
1131
+ let codex_home = tempdir().expect("tempdir");
1132
+ let service = CloudConfigBundleService::new(
1133
+ auth_manager_with_plan("enterprise").await,
1134
+ fetcher.clone(),
1135
+ codex_home.path().to_path_buf(),
1136
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1137
+ );
1138
+
1139
+ let handle = tokio::spawn(async move { service.load_startup_bundle().await });
1140
+ tokio::task::yield_now().await;
1141
+ tokio::time::advance(Duration::from_secs(5)).await;
1142
+ tokio::task::yield_now().await;
1143
+
1144
+ let err = handle
1145
+ .await
1146
+ .expect("cloud config bundle task")
1147
+ .expect_err("cloud config bundle retry exhaustion should fail closed");
1148
+ assert_eq!(err.to_string(), CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE);
1149
+ assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed);
1150
+ assert_eq!(
1151
+ fetcher.request_count.load(Ordering::SeqCst),
1152
+ CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS
1153
+ );
1154
+ }
1155
+
1156
+ #[tokio::test]
1157
+ async fn refresh_from_remote_updates_cached_bundle() {
1158
+ let replacement_bundle = CloudConfigBundle {
1159
+ config_toml: CloudConfigTomlBundle::default(),
1160
+ requirements_toml: CloudRequirementsTomlBundle {
1161
+ enterprise_managed: vec![CloudRequirementsFragment {
1162
+ id: "req_2".to_string(),
1163
+ name: "Replacement requirements".to_string(),
1164
+ contents: "allowed_approval_policies = [\"on-request\"]".to_string(),
1165
+ }],
1166
+ },
1167
+ };
1168
+ let codex_home = tempdir().expect("tempdir");
1169
+ let fetcher = Arc::new(SequenceBundleClient::new(vec![
1170
+ Ok(test_bundle()),
1171
+ Ok(replacement_bundle.clone()),
1172
+ ]));
1173
+ let service = CloudConfigBundleService::new(
1174
+ auth_manager_with_plan("business").await,
1175
+ fetcher,
1176
+ codex_home.path().to_path_buf(),
1177
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1178
+ );
1179
+
1180
+ assert_eq!(service.get_latest().await, Ok(Some(test_bundle())));
1181
+ assert!(service.refresh_cache_once().await);
1182
+ assert_eq!(
1183
+ service.get_latest().await,
1184
+ Ok(Some(replacement_bundle.clone()))
1185
+ );
1186
+
1187
+ let cache = create_test_cache(codex_home.path());
1188
+ let signed_payload = cache
1189
+ .load(Some("user-12345"), Some("account-12345"))
1190
+ .await
1191
+ .expect("load cache");
1192
+ assert_eq!(signed_payload.bundle, replacement_bundle);
1193
+ }
1194
+
1195
+ #[tokio::test(start_paused = true)]
1196
+ async fn production_loader_recovers_startup_timeouts_in_background() {
1197
+ let codex_home = tempdir().expect("tempdir");
1198
+ let fetcher = Arc::new(SequenceBundleClient {
1199
+ timeout_attempts: 2,
1200
+ ..SequenceBundleClient::new(vec![Ok(test_bundle())])
1201
+ });
1202
+ let service = CloudConfigBundleService::new(
1203
+ auth_manager_with_plan("business").await,
1204
+ Arc::clone(&fetcher),
1205
+ codex_home.path().to_path_buf(),
1206
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1207
+ );
1208
+ let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service);
1209
+ let (initial, ()) = tokio::join!(loader.get(), async {
1210
+ fetcher.request_started.notified().await;
1211
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await;
1212
+ });
1213
+ assert_eq!(
1214
+ initial
1215
+ .as_ref()
1216
+ .expect_err("startup should time out")
1217
+ .code(),
1218
+ CloudConfigBundleLoadErrorCode::Timeout
1219
+ );
1220
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
1221
+
1222
+ for attempt in 2..=3 {
1223
+ tokio::time::timeout(
1224
+ CLOUD_CONFIG_BUNDLE_TIMEOUT_RETRY_INTERVAL + Duration::from_millis(1),
1225
+ fetcher.request_started.notified(),
1226
+ )
1227
+ .await
1228
+ .expect("the worker should retry before the normal refresh interval");
1229
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), attempt);
1230
+ if attempt == 2 {
1231
+ // Readers keep returning the snapshot while the worker's retry is pending.
1232
+ let (first, second) = tokio::join!(loader.get(), loader.get());
1233
+ assert_eq!((first, second), (initial.clone(), initial.clone()));
1234
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
1235
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await;
1236
+ }
1237
+ }
1238
+
1239
+ let recovery_deadline = std::time::Instant::now() + Duration::from_secs(5);
1240
+ while loader.get().await != Ok(Some(test_bundle())) {
1241
+ assert!(
1242
+ std::time::Instant::now() < recovery_deadline,
1243
+ "the worker should publish the recovered bundle"
1244
+ );
1245
+ tokio::task::yield_now().await;
1246
+ }
1247
+ let config = ConfigBuilder::default()
1248
+ .codex_home(codex_home.path().to_path_buf())
1249
+ .cloud_config_bundle(loader.clone())
1250
+ .build()
1251
+ .await
1252
+ .expect("later configuration loads should apply the recovered bundle");
1253
+ assert_eq!(config.model.as_deref(), Some("gpt-5"));
1254
+ assert_eq!(
1255
+ config.permissions.approval_policy.value(),
1256
+ codex_protocol::protocol::AskForApproval::Never
1257
+ );
1258
+
1259
+ tokio::time::timeout(
1260
+ CLOUD_CONFIG_BUNDLE_TIMEOUT_RETRY_INTERVAL + Duration::from_millis(1),
1261
+ fetcher.request_started.notified(),
1262
+ )
1263
+ .await
1264
+ .expect_err("successful recovery should restore the normal refresh interval");
1265
+ tokio::time::timeout(
1266
+ CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL,
1267
+ fetcher.request_started.notified(),
1268
+ )
1269
+ .await
1270
+ .expect("normal background refresh should continue after recovery");
1271
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 4);
1272
+ }
1273
+
1274
+ #[tokio::test(start_paused = true)]
1275
+ async fn production_loader_restores_normal_refresh_interval_after_non_timeout_error() {
1276
+ let codex_home = tempdir().expect("tempdir");
1277
+ let fetcher = Arc::new(SequenceBundleClient {
1278
+ timeout_attempts: 1,
1279
+ ..SequenceBundleClient::new(vec![Ok(invalid_config_bundle()), Ok(test_bundle())])
1280
+ });
1281
+ let service = CloudConfigBundleService::new(
1282
+ auth_manager_with_plan("business").await,
1283
+ Arc::clone(&fetcher),
1284
+ codex_home.path().to_path_buf(),
1285
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1286
+ );
1287
+ let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service);
1288
+ let (initial, ()) = tokio::join!(loader.get(), async {
1289
+ fetcher.request_started.notified().await;
1290
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await;
1291
+ });
1292
+ assert_eq!(
1293
+ initial.expect_err("startup should time out").code(),
1294
+ CloudConfigBundleLoadErrorCode::Timeout
1295
+ );
1296
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
1297
+
1298
+ tokio::time::timeout(
1299
+ CLOUD_CONFIG_BUNDLE_TIMEOUT_RETRY_INTERVAL + Duration::from_millis(1),
1300
+ fetcher.request_started.notified(),
1301
+ )
1302
+ .await
1303
+ .expect("the worker should retry the startup timeout promptly");
1304
+ let refresh_deadline = std::time::Instant::now() + Duration::from_secs(5);
1305
+ loop {
1306
+ let error = loader
1307
+ .get()
1308
+ .await
1309
+ .expect_err("the invalid bundle should fail closed");
1310
+ if error.code() == CloudConfigBundleLoadErrorCode::InvalidBundle {
1311
+ break;
1312
+ }
1313
+ assert!(
1314
+ std::time::Instant::now() < refresh_deadline,
1315
+ "the retry should replace the cached timeout with the validation error"
1316
+ );
1317
+ tokio::task::yield_now().await;
1318
+ }
1319
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
1320
+
1321
+ tokio::time::timeout(
1322
+ CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL - Duration::from_secs(1),
1323
+ fetcher.request_started.notified(),
1324
+ )
1325
+ .await
1326
+ .expect_err("the non-timeout error should stop fast retries");
1327
+ tokio::time::timeout(
1328
+ Duration::from_secs(1) + Duration::from_millis(1),
1329
+ fetcher.request_started.notified(),
1330
+ )
1331
+ .await
1332
+ .expect("refresh should resume at the normal interval");
1333
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 3);
1334
+ }
1335
+
1336
+ #[tokio::test(start_paused = true)]
1337
+ async fn production_loader_refreshes_later_configs_and_preserves_failed_refreshes() {
1338
+ let codex_home = tempdir().expect("tempdir");
1339
+ let initial_bundle = test_bundle();
1340
+ let refreshed_bundle = CloudConfigBundle {
1341
+ config_toml: CloudConfigTomlBundle {
1342
+ enterprise_managed: vec![CloudConfigFragment {
1343
+ id: "cfg_refreshed".to_string(),
1344
+ name: "Refreshed config".to_string(),
1345
+ contents: "model = \"gpt-5-refreshed\"".to_string(),
1346
+ }],
1347
+ },
1348
+ requirements_toml: initial_bundle.requirements_toml.clone(),
1349
+ };
1350
+ let mut responses = vec![Ok(initial_bundle.clone()), Ok(refreshed_bundle.clone())];
1351
+ responses.extend(vec![Err(request_error()); CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS]);
1352
+ let fetcher = Arc::new(SequenceBundleClient::new(responses));
1353
+ let service = CloudConfigBundleService::new(
1354
+ auth_manager_with_plan("business").await,
1355
+ Arc::clone(&fetcher),
1356
+ codex_home.path().to_path_buf(),
1357
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1358
+ );
1359
+ let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service);
1360
+
1361
+ let (first, second) = tokio::join!(loader.get(), loader.get());
1362
+ assert_eq!(first, Ok(Some(initial_bundle.clone())));
1363
+ assert_eq!(second, Ok(Some(initial_bundle)));
1364
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
1365
+ tokio::task::yield_now().await;
1366
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL + Duration::from_millis(1))
1367
+ .await;
1368
+ let refresh_deadline = std::time::Instant::now() + Duration::from_secs(5);
1369
+ while loader.get().await != Ok(Some(refreshed_bundle.clone())) {
1370
+ assert!(
1371
+ std::time::Instant::now() < refresh_deadline,
1372
+ "the production refresh task should update the latest bundle"
1373
+ );
1374
+ tokio::task::yield_now().await;
1375
+ }
1376
+ let refreshed_config = ConfigBuilder::default()
1377
+ .codex_home(codex_home.path().to_path_buf())
1378
+ .cloud_config_bundle(loader.clone())
1379
+ .build()
1380
+ .await
1381
+ .expect("later session config should load the refreshed bundle");
1382
+ assert_eq!(refreshed_config.model.as_deref(), Some("gpt-5-refreshed"));
1383
+
1384
+ tokio::task::yield_now().await;
1385
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL + Duration::from_millis(1))
1386
+ .await;
1387
+ let failed_refresh_deadline = std::time::Instant::now() + Duration::from_secs(5);
1388
+ for expected_request_count in 3..=2 + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
1389
+ if expected_request_count > 3 {
1390
+ tokio::task::yield_now().await;
1391
+ tokio::time::advance(Duration::from_secs(2)).await;
1392
+ }
1393
+ while fetcher.request_count.load(Ordering::SeqCst) < expected_request_count {
1394
+ assert!(
1395
+ std::time::Instant::now() < failed_refresh_deadline,
1396
+ "the production refresh task should retry the failed bundle request"
1397
+ );
1398
+ tokio::task::yield_now().await;
1399
+ }
1400
+ }
1401
+
1402
+ assert_eq!(loader.get().await, Ok(Some(refreshed_bundle)));
1403
+ }
1404
+
1405
+ #[tokio::test(start_paused = true)]
1406
+ async fn refresh_stops_on_replacement_or_after_the_last_loader_clone() {
1407
+ let codex_home = tempdir().expect("tempdir");
1408
+ let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
1409
+ let service = CloudConfigBundleService::new(
1410
+ auth_manager_with_plan("business").await,
1411
+ Arc::clone(&fetcher),
1412
+ codex_home.path().to_path_buf(),
1413
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1414
+ );
1415
+ let (loader, abort_handle) =
1416
+ crate::bundle_loader::cloud_config_bundle_loader_for_service(service);
1417
+ let task_slot = std::sync::Mutex::new(None);
1418
+ crate::bundle_loader::replace_refresh_task(&task_slot, abort_handle);
1419
+ let cloned_loader = loader.clone();
1420
+ assert_eq!(loader.get().await, Ok(Some(test_bundle())));
1421
+ tokio::task::yield_now().await;
1422
+
1423
+ drop(loader);
1424
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL + Duration::from_millis(1))
1425
+ .await;
1426
+ let refresh_deadline = std::time::Instant::now() + Duration::from_secs(5);
1427
+ while fetcher.request_count.load(Ordering::SeqCst) < 2 {
1428
+ assert!(
1429
+ std::time::Instant::now() < refresh_deadline,
1430
+ "the refresh should remain active while another loader clone exists"
1431
+ );
1432
+ tokio::task::yield_now().await;
1433
+ }
1434
+
1435
+ let replacement_fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
1436
+ let replacement_service = CloudConfigBundleService::new(
1437
+ auth_manager_with_plan_and_identity(
1438
+ "business",
1439
+ Some("user-replacement"),
1440
+ Some("account-replacement"),
1441
+ )
1442
+ .await,
1443
+ Arc::clone(&replacement_fetcher),
1444
+ codex_home.path().to_path_buf(),
1445
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1446
+ );
1447
+ let (replacement_loader, replacement_handle) =
1448
+ crate::bundle_loader::cloud_config_bundle_loader_for_service(replacement_service);
1449
+ let replacement_task = replacement_handle.clone();
1450
+ crate::bundle_loader::replace_refresh_task(&task_slot, replacement_handle);
1451
+ assert_eq!(replacement_loader.get().await, Ok(Some(test_bundle())));
1452
+
1453
+ tokio::task::yield_now().await;
1454
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL + Duration::from_millis(1))
1455
+ .await;
1456
+ let refresh_deadline = std::time::Instant::now() + Duration::from_secs(5);
1457
+ while replacement_fetcher.request_count.load(Ordering::SeqCst) < 2 {
1458
+ assert!(
1459
+ std::time::Instant::now() < refresh_deadline,
1460
+ "the replacement refresher should stay active"
1461
+ );
1462
+ tokio::task::yield_now().await;
1463
+ }
1464
+ assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
1465
+
1466
+ drop(cloned_loader);
1467
+ tokio::task::yield_now().await;
1468
+ assert!(!replacement_task.is_finished());
1469
+
1470
+ drop(replacement_loader);
1471
+ tokio::task::yield_now().await;
1472
+ assert!(replacement_task.is_finished());
1473
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL + Duration::from_secs(1)).await;
1474
+ tokio::task::yield_now().await;
1475
+ assert_eq!(replacement_fetcher.request_count.load(Ordering::SeqCst), 2);
1476
+ }
1477
+
1478
+ #[tokio::test(start_paused = true)]
1479
+ async fn dropping_loader_cancels_in_flight_startup_and_refresh() {
1480
+ for starts_from_cache in [false, true] {
1481
+ let codex_home = tempdir().expect("tempdir");
1482
+ if starts_from_cache {
1483
+ create_test_cache(codex_home.path())
1484
+ .save(
1485
+ Some("user-12345".to_string()),
1486
+ Some("account-12345".to_string()),
1487
+ test_bundle(),
1488
+ )
1489
+ .await
1490
+ .expect("write initial cache");
1491
+ }
1492
+ let request_started = Arc::new(tokio::sync::Notify::new());
1493
+ let request_cancelled = Arc::new(tokio::sync::Notify::new());
1494
+ let service = CloudConfigBundleService::new(
1495
+ auth_manager_with_plan("business").await,
1496
+ Arc::new(NotifyingPendingBundleClient {
1497
+ request_started: Arc::clone(&request_started),
1498
+ request_cancelled: Arc::clone(&request_cancelled),
1499
+ }),
1500
+ codex_home.path().to_path_buf(),
1501
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1502
+ );
1503
+ let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service);
1504
+ if starts_from_cache {
1505
+ assert_eq!(loader.get().await, Ok(Some(test_bundle())));
1506
+ tokio::task::yield_now().await;
1507
+ tokio::time::advance(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await;
1508
+ }
1509
+ request_started.notified().await;
1510
+
1511
+ drop(loader);
1512
+
1513
+ tokio::time::timeout(Duration::from_secs(1), request_cancelled.notified())
1514
+ .await
1515
+ .expect("loader drop should cancel the in-flight fetch");
1516
+ }
1517
+ }
1518
+
1519
+ #[tokio::test(start_paused = true)]
1520
+ async fn refresh_can_clear_preserve_and_restore_the_latest_bundle() {
1521
+ let codex_home = tempdir().expect("tempdir");
1522
+ let mut responses = vec![Ok(test_bundle()), Ok(CloudConfigBundle::default())];
1523
+ responses.extend(vec![Err(request_error()); CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS]);
1524
+ responses.push(Ok(test_bundle()));
1525
+ let service = Arc::new(CloudConfigBundleService::new(
1526
+ auth_manager_with_plan("business").await,
1527
+ Arc::new(SequenceBundleClient::new(responses)),
1528
+ codex_home.path().to_path_buf(),
1529
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1530
+ ));
1531
+
1532
+ assert_eq!(service.get_latest().await, Ok(Some(test_bundle())));
1533
+ assert!(service.refresh_cache_once().await);
1534
+ assert_eq!(service.get_latest().await, Ok(None));
1535
+
1536
+ let refresh_service = Arc::clone(&service);
1537
+ let refresh = tokio::spawn(async move { refresh_service.refresh_cache_once().await });
1538
+ tokio::task::yield_now().await;
1539
+ tokio::time::advance(Duration::from_secs(5)).await;
1540
+ tokio::task::yield_now().await;
1541
+ assert!(refresh.await.expect("failed refresh task"));
1542
+ assert_eq!(service.get_latest().await, Ok(None));
1543
+
1544
+ assert!(service.refresh_cache_once().await);
1545
+ assert_eq!(service.get_latest().await, Ok(Some(test_bundle())));
1546
+ }
1547
+
1548
+ #[tokio::test(start_paused = true)]
1549
+ async fn refresh_replaces_initial_errors_and_recovers_with_success() {
1550
+ let codex_home = tempdir().expect("tempdir");
1551
+ let initial_error = BundleRequestError::Retryable(RetryableFailureKind::Request {
1552
+ status_code: Some(500),
1553
+ });
1554
+ let refresh_error = BundleRequestError::Retryable(RetryableFailureKind::Request {
1555
+ status_code: Some(503),
1556
+ });
1557
+ let mut responses = vec![Err(initial_error); CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS];
1558
+ responses.extend(vec![Err(refresh_error); CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS]);
1559
+ responses.push(Ok(test_bundle()));
1560
+ let service = Arc::new(CloudConfigBundleService::new(
1561
+ auth_manager_with_plan("business").await,
1562
+ Arc::new(SequenceBundleClient::new(responses)),
1563
+ codex_home.path().to_path_buf(),
1564
+ CLOUD_CONFIG_BUNDLE_TIMEOUT,
1565
+ ));
1566
+
1567
+ let initial_service = Arc::clone(&service);
1568
+ let initial = tokio::spawn(async move { initial_service.get_latest().await });
1569
+ tokio::task::yield_now().await;
1570
+ tokio::time::advance(Duration::from_secs(5)).await;
1571
+ tokio::task::yield_now().await;
1572
+ let initial_error = initial
1573
+ .await
1574
+ .expect("initial task")
1575
+ .expect_err("initial fetch should fail");
1576
+ assert_eq!(initial_error.status_code(), Some(500));
1577
+
1578
+ let refresh_service = Arc::clone(&service);
1579
+ let refresh = tokio::spawn(async move { refresh_service.refresh_cache_once().await });
1580
+ tokio::task::yield_now().await;
1581
+ tokio::time::advance(Duration::from_secs(5)).await;
1582
+ tokio::task::yield_now().await;
1583
+ assert!(refresh.await.expect("failed refresh task"));
1584
+ let latest_error = service
1585
+ .get_latest()
1586
+ .await
1587
+ .expect_err("latest failed refresh should replace the initial error");
1588
+ assert_eq!(latest_error.status_code(), Some(503));
1589
+
1590
+ assert!(service.refresh_cache_once().await);
1591
+ assert_eq!(service.get_latest().await, Ok(Some(test_bundle())));
1592
+ }
1593
+
1594
+ #[test]
1595
+ fn bundle_response_conversion_preserves_fragment_order() {
1596
+ let response = ConfigBundleResponse {
1597
+ config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml {
1598
+ enterprise_managed: Some(Some(vec![
1599
+ DeliveredTomlFragment::new(
1600
+ "cfg_high".to_string(),
1601
+ "High config".to_string(),
1602
+ "model = \"high\"".to_string(),
1603
+ ),
1604
+ DeliveredTomlFragment::new(
1605
+ "cfg_low".to_string(),
1606
+ "Low config".to_string(),
1607
+ "model = \"low\"".to_string(),
1608
+ ),
1609
+ ])),
1610
+ managed_layers: None,
1611
+ }))),
1612
+ requirements_toml: Some(Some(Box::new(
1613
+ codex_backend_client::DeliveredRequirementsToml {
1614
+ enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new(
1615
+ "req_high".to_string(),
1616
+ "High requirements".to_string(),
1617
+ "allowed_approval_policies = [\"never\"]".to_string(),
1618
+ )])),
1619
+ managed_layers: None,
1620
+ },
1621
+ ))),
1622
+ };
1623
+
1624
+ assert_eq!(
1625
+ bundle_from_response(response),
1626
+ CloudConfigBundle {
1627
+ config_toml: CloudConfigTomlBundle {
1628
+ enterprise_managed: vec![
1629
+ CloudConfigFragment {
1630
+ id: "cfg_high".to_string(),
1631
+ name: "High config".to_string(),
1632
+ contents: "model = \"high\"".to_string(),
1633
+ },
1634
+ CloudConfigFragment {
1635
+ id: "cfg_low".to_string(),
1636
+ name: "Low config".to_string(),
1637
+ contents: "model = \"low\"".to_string(),
1638
+ },
1639
+ ],
1640
+ },
1641
+ requirements_toml: CloudRequirementsTomlBundle {
1642
+ enterprise_managed: vec![CloudRequirementsFragment {
1643
+ id: "req_high".to_string(),
1644
+ name: "High requirements".to_string(),
1645
+ contents: "allowed_approval_policies = [\"never\"]".to_string(),
1646
+ }],
1647
+ },
1648
+ }
1649
+ );
1650
+ }
1651
+
1652
+ #[test]
1653
+ fn bundle_response_conversion_treats_missing_sections_as_empty() {
1654
+ assert_eq!(
1655
+ bundle_from_response(ConfigBundleResponse::new()),
1656
+ CloudConfigBundle::default()
1657
+ );
1658
+ }
codex-rs/cloud-config/src/validation.rs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_config::AbsolutePathBuf;
2
+ use codex_config::CloudConfigBundle;
3
+ use codex_config::CloudConfigBundleLayers;
4
+ use codex_config::CloudConfigBundleLoadError;
5
+ use codex_config::CloudConfigBundleLoadErrorCode;
6
+ use codex_config::compose_requirements;
7
+ use codex_config::config_toml::validate_model_providers;
8
+
9
+ pub(crate) fn validate_bundle(
10
+ bundle: &CloudConfigBundle,
11
+ base_dir: &AbsolutePathBuf,
12
+ ) -> Result<(), CloudConfigBundleLoadError> {
13
+ let bundle_layers =
14
+ CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| {
15
+ CloudConfigBundleLoadError::new(
16
+ CloudConfigBundleLoadErrorCode::InvalidBundle,
17
+ /*status_code*/ None,
18
+ format!("invalid cloud config bundle: {err}"),
19
+ )
20
+ })?;
21
+ let CloudConfigBundleLayers {
22
+ enterprise_managed_config: _,
23
+ enterprise_managed_requirements,
24
+ } = bundle_layers;
25
+
26
+ let requirements = compose_requirements(enterprise_managed_requirements).map_err(|err| {
27
+ CloudConfigBundleLoadError::new(
28
+ CloudConfigBundleLoadErrorCode::InvalidBundle,
29
+ /*status_code*/ None,
30
+ format!("invalid cloud config bundle: {err}"),
31
+ )
32
+ })?;
33
+ if let Some(providers) = requirements.and_then(|requirements| requirements.model_providers) {
34
+ validate_model_providers(&providers).map_err(|err| {
35
+ CloudConfigBundleLoadError::new(
36
+ CloudConfigBundleLoadErrorCode::InvalidBundle,
37
+ /*status_code*/ None,
38
+ format!("invalid cloud config bundle: {err}"),
39
+ )
40
+ })?;
41
+ }
42
+
43
+ Ok(())
44
+ }
45
+
46
+ #[cfg(test)]
47
+ #[path = "validation_tests.rs"]
48
+ mod tests;
codex-rs/cloud-config/src/validation_tests.rs ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use codex_config::CloudRequirementsFragment;
3
+ use pretty_assertions::assert_eq;
4
+ use tempfile::tempdir;
5
+
6
+ #[test]
7
+ fn cloud_fragments_combine_before_provider_validation() {
8
+ let home = tempdir().expect("tempdir");
9
+ let base_dir = AbsolutePathBuf::from_absolute_path(home.path()).expect("absolute path");
10
+ let mut bundle = CloudConfigBundle::default();
11
+ // Cloud fragments arrive highest-priority first.
12
+ bundle.requirements_toml.enterprise_managed = vec![
13
+ CloudRequirementsFragment {
14
+ id: "high".to_string(),
15
+ name: "URL".to_string(),
16
+ contents: "[model_providers.gateway]\nbase_url = 'https://gateway.example/v1'\n[model_providers.gateway.auth]\ntimeout_ms = 10000\ncwd = 'auth'"
17
+ .to_string(),
18
+ },
19
+ CloudRequirementsFragment {
20
+ id: "low".to_string(),
21
+ name: "Name".to_string(),
22
+ contents: "[model_providers.gateway]\nname = 'Gateway'\n[model_providers.gateway.auth]\ncommand = 'get-token'".to_string(),
23
+ },
24
+ ];
25
+ assert_eq!(validate_bundle(&bundle, &base_dir), Ok(()));
26
+ }
27
+
28
+ #[test]
29
+ fn cloud_bedrock_overrides_accept_supported_fields() {
30
+ let home = tempdir().expect("tempdir");
31
+ let base_dir = AbsolutePathBuf::from_absolute_path(home.path()).expect("absolute path");
32
+ for provider in ["amazon-bedrock", "amazon-bedrock-runtime"] {
33
+ let mut bundle = CloudConfigBundle::default();
34
+ bundle.requirements_toml.enterprise_managed = vec![CloudRequirementsFragment {
35
+ id: "bedrock".to_string(),
36
+ name: "Bedrock".to_string(),
37
+ contents: format!(
38
+ r#"
39
+ [model_providers.{provider}]
40
+ base_url = "https://bedrock.example"
41
+ [model_providers.{provider}.http_headers]
42
+ X-Managed = "required"
43
+ [model_providers.{provider}.aws]
44
+ profile = "managed"
45
+ region = "us-east-1"
46
+ "#
47
+ ),
48
+ }];
49
+ assert_eq!(validate_bundle(&bundle, &base_dir), Ok(()));
50
+ }
51
+ }
codex-rs/codex-backend-openapi-models/src/lib.rs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #![allow(clippy::unwrap_used, clippy::expect_used)]
2
+
3
+ // Re-export generated OpenAPI models.
4
+ // The regen script populates `src/models/*.rs` and writes `src/models/mod.rs`.
5
+ // This module intentionally contains no hand-written types.
6
+ pub mod models;
codex-rs/codex-backend-openapi-models/src/models/additional_rate_limit_details.rs ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct AdditionalRateLimitDetails {
17
+ #[serde(rename = "limit_name")]
18
+ pub limit_name: String,
19
+ #[serde(rename = "metered_feature")]
20
+ pub metered_feature: String,
21
+ #[serde(
22
+ rename = "rate_limit",
23
+ default,
24
+ with = "::serde_with::rust::double_option",
25
+ skip_serializing_if = "Option::is_none"
26
+ )]
27
+ pub rate_limit: Option<Option<Box<models::RateLimitStatusDetails>>>,
28
+ }
29
+
30
+ impl AdditionalRateLimitDetails {
31
+ pub fn new(limit_name: String, metered_feature: String) -> AdditionalRateLimitDetails {
32
+ AdditionalRateLimitDetails {
33
+ limit_name,
34
+ metered_feature,
35
+ rate_limit: None,
36
+ }
37
+ }
38
+ }
codex-rs/codex-backend-openapi-models/src/models/analytics.rs ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Generated by scripts/generate_analytics_models.py; do not edit.
2
+ // Selected backend schema SHA-256: 8dad4dcab85a89fd8091236929727587118dcaad334e10148ae8cf95dd640cc8
3
+ // String enums remain open for forward compatibility.
4
+
5
+ use serde::Deserialize;
6
+ use serde::Serialize;
7
+
8
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
9
+ pub struct ActiveUsersSummary {
10
+ pub total_users: i64,
11
+ pub clients: Vec<ClientActiveUsersCount>,
12
+ pub models: Option<Vec<ModelActivitySummary>>,
13
+ }
14
+
15
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
16
+ pub struct ClientActiveUsersCount {
17
+ pub client_id: String,
18
+ pub users: i64,
19
+ }
20
+
21
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
22
+ pub struct ClientWorkspaceUsageCount {
23
+ pub client_id: String,
24
+ pub users: i64,
25
+ pub threads: i64,
26
+ pub turns: i64,
27
+ pub credits: f64,
28
+ pub cost_usd: Option<String>,
29
+ pub on_demand_credits: Option<f64>,
30
+ pub uncached_text_input_tokens: Option<i64>,
31
+ pub cached_text_input_tokens: Option<i64>,
32
+ pub text_output_tokens: Option<i64>,
33
+ pub text_total_tokens: Option<i64>,
34
+ }
35
+
36
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
37
+ pub struct ConsumptionUsageGroup {
38
+ pub dimensions: std::collections::BTreeMap<String, String>,
39
+ pub is_other: Option<bool>,
40
+ pub credits: f64,
41
+ pub on_demand_credits: Option<f64>,
42
+ pub uncached_text_input_tokens: Option<i64>,
43
+ pub cached_text_input_tokens: Option<i64>,
44
+ pub text_output_tokens: Option<i64>,
45
+ pub text_total_tokens: Option<i64>,
46
+ pub total_tokens: Option<i64>,
47
+ }
48
+
49
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
50
+ pub struct CreditUsageEventBySurface {
51
+ pub date: String,
52
+ pub product_surface: String,
53
+ pub credit_amount: f64,
54
+ pub usage_id: Option<String>,
55
+ }
56
+
57
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
58
+ pub struct CreditUsageEventsResponse {
59
+ pub data: Vec<CreditUsageEventBySurface>,
60
+ }
61
+
62
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
63
+ pub struct CurrentUserCreditUsageDatum {
64
+ pub date: String,
65
+ pub values: std::collections::BTreeMap<String, f64>,
66
+ }
67
+
68
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
69
+ pub struct CurrentUserCreditUsageResponse {
70
+ pub breakdown: String,
71
+ pub data: Vec<CurrentUserCreditUsageDatum>,
72
+ pub series: Vec<CurrentUserCreditUsageSeries>,
73
+ pub unit: Option<String>,
74
+ pub data_freshness_ts: Option<String>,
75
+ }
76
+
77
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
78
+ pub struct CurrentUserCreditUsageSeries {
79
+ pub key: String,
80
+ pub label: String,
81
+ pub total: f64,
82
+ }
83
+
84
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
85
+ pub struct DailyProductSurfaceUsage {
86
+ pub attribution: Option<Vec<PersonalUsageAttribution>>,
87
+ pub date: String,
88
+ pub product_surface_usage_values: std::collections::BTreeMap<String, f64>,
89
+ pub premium_usage_values: Option<ProductSurfacePremiumUsageValues>,
90
+ pub models: Option<Vec<ModelUsage>>,
91
+ pub groups: Option<Vec<ConsumptionUsageGroup>>,
92
+ }
93
+
94
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
95
+ pub struct DailyProductSurfaceUsageResponse {
96
+ pub data: Vec<DailyProductSurfaceUsage>,
97
+ pub units: Option<String>,
98
+ pub data_freshness_ts: Option<String>,
99
+ pub group_by: Option<String>,
100
+ pub breakdown_by: Option<Vec<String>>,
101
+ }
102
+
103
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
104
+ pub struct DailySkillUsageMetricsResponse {
105
+ pub data: Vec<DailySkillUsageOverview>,
106
+ pub data_freshness_ts: Option<String>,
107
+ pub group_by: Option<String>,
108
+ }
109
+
110
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
111
+ pub struct DailySkillUsageOverview {
112
+ pub date: String,
113
+ pub skill_usage_overviews: Vec<SkillUsageOverview>,
114
+ }
115
+
116
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
117
+ pub struct DailyWorkspaceUsageCount {
118
+ pub date: String,
119
+ pub totals: WorkspaceUsageCount,
120
+ pub clients: Vec<ClientWorkspaceUsageCount>,
121
+ pub models: Option<Vec<ModelUsage>>,
122
+ pub groups: Option<Vec<WorkspaceUsageGroup>>,
123
+ }
124
+
125
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
126
+ pub struct DailyWorkspaceUsageCountResponse {
127
+ pub data: Vec<DailyWorkspaceUsageCount>,
128
+ pub balance_unit: Option<String>,
129
+ pub active_users_summary: Option<ActiveUsersSummary>,
130
+ pub group_by: Option<String>,
131
+ pub breakdown_by: Option<Vec<String>>,
132
+ }
133
+
134
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
135
+ pub struct ModelActivitySummary {
136
+ pub model: String,
137
+ pub users: i64,
138
+ pub threads: i64,
139
+ pub turns: i64,
140
+ }
141
+
142
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
143
+ pub struct ModelUsage {
144
+ pub model: String,
145
+ pub speed: Option<String>,
146
+ pub credits: f64,
147
+ pub cost_usd: Option<String>,
148
+ pub users: Option<i64>,
149
+ pub threads: Option<i64>,
150
+ pub turns: Option<i64>,
151
+ pub on_demand_credits: Option<f64>,
152
+ pub uncached_text_input_tokens: Option<i64>,
153
+ pub cached_text_input_tokens: Option<i64>,
154
+ pub text_output_tokens: Option<i64>,
155
+ pub text_total_tokens: Option<i64>,
156
+ pub total_tokens: Option<i64>,
157
+ }
158
+
159
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
160
+ pub struct PersonalUsageAttribution {
161
+ pub thread_source: String,
162
+ pub turn_trigger: String,
163
+ pub model: String,
164
+ pub surface: String,
165
+ pub value: f64,
166
+ }
167
+
168
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
169
+ pub struct PluginUsageBucket {
170
+ pub date: String,
171
+ pub plugin_usage_overviews: Vec<PluginUsageOverview>,
172
+ }
173
+
174
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
175
+ pub struct PluginUsageMetricsResponse {
176
+ pub data: Vec<PluginUsageBucket>,
177
+ pub data_freshness_ts: Option<String>,
178
+ pub group_by: Option<String>,
179
+ }
180
+
181
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
182
+ pub struct PluginUsageOverview {
183
+ pub plugin_id: Option<String>,
184
+ pub plugin_name: String,
185
+ pub display_name: String,
186
+ pub marketplace: Option<String>,
187
+ pub invocation_counts: i64,
188
+ }
189
+
190
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
191
+ pub struct ProductSurfacePremiumUsageValues {
192
+ pub total_usage_credits: std::collections::BTreeMap<String, f64>,
193
+ pub credit_usage_credits: std::collections::BTreeMap<String, f64>,
194
+ pub uncached_text_input_tokens_by_surface: std::collections::BTreeMap<String, i64>,
195
+ pub cached_text_input_tokens_by_surface: std::collections::BTreeMap<String, i64>,
196
+ pub text_output_tokens_by_surface: std::collections::BTreeMap<String, i64>,
197
+ pub text_total_tokens_by_surface: std::collections::BTreeMap<String, i64>,
198
+ pub total_tokens_by_surface: Option<std::collections::BTreeMap<String, i64>>,
199
+ }
200
+
201
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
202
+ pub struct SkillUsageOverview {
203
+ pub skill_name: String,
204
+ pub display_name: String,
205
+ pub skill_ids: Vec<String>,
206
+ pub invocation_counts: i64,
207
+ }
208
+
209
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
210
+ pub struct WorkspaceUsageCount {
211
+ pub users: i64,
212
+ pub threads: i64,
213
+ pub turns: i64,
214
+ pub credits: f64,
215
+ pub cost_usd: Option<String>,
216
+ pub on_demand_credits: Option<f64>,
217
+ pub uncached_text_input_tokens: Option<i64>,
218
+ pub cached_text_input_tokens: Option<i64>,
219
+ pub text_output_tokens: Option<i64>,
220
+ pub text_total_tokens: Option<i64>,
221
+ }
222
+
223
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
224
+ pub struct WorkspaceUsageGroup {
225
+ pub dimensions: std::collections::BTreeMap<String, String>,
226
+ pub is_other: Option<bool>,
227
+ pub users: i64,
228
+ pub turns: i64,
229
+ pub cost_usd: Option<String>,
230
+ }
codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct CodeTaskDetailsResponse {
17
+ #[serde(rename = "task")]
18
+ pub task: Box<models::TaskResponse>,
19
+ #[serde(rename = "current_user_turn", skip_serializing_if = "Option::is_none")]
20
+ pub current_user_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
21
+ #[serde(
22
+ rename = "current_assistant_turn",
23
+ skip_serializing_if = "Option::is_none"
24
+ )]
25
+ pub current_assistant_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
26
+ #[serde(
27
+ rename = "current_diff_task_turn",
28
+ skip_serializing_if = "Option::is_none"
29
+ )]
30
+ pub current_diff_task_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
31
+ }
32
+
33
+ impl CodeTaskDetailsResponse {
34
+ pub fn new(task: models::TaskResponse) -> CodeTaskDetailsResponse {
35
+ CodeTaskDetailsResponse {
36
+ task: Box::new(task),
37
+ current_user_turn: None,
38
+ current_assistant_turn: None,
39
+ current_diff_task_turn: None,
40
+ }
41
+ }
42
+ }
codex-rs/codex-backend-openapi-models/src/models/config_bundle_response.rs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct ConfigBundleResponse {
17
+ #[serde(
18
+ rename = "config_toml",
19
+ default,
20
+ with = "::serde_with::rust::double_option",
21
+ skip_serializing_if = "Option::is_none"
22
+ )]
23
+ pub config_toml: Option<Option<Box<models::DeliveredConfigToml>>>,
24
+ #[serde(
25
+ rename = "requirements_toml",
26
+ default,
27
+ with = "::serde_with::rust::double_option",
28
+ skip_serializing_if = "Option::is_none"
29
+ )]
30
+ pub requirements_toml: Option<Option<Box<models::DeliveredRequirementsToml>>>,
31
+ }
32
+
33
+ impl ConfigBundleResponse {
34
+ pub fn new() -> ConfigBundleResponse {
35
+ ConfigBundleResponse {
36
+ config_toml: None,
37
+ requirements_toml: None,
38
+ }
39
+ }
40
+ }
codex-rs/codex-backend-openapi-models/src/models/config_file_response.rs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use serde::Deserialize;
12
+ use serde::Serialize;
13
+
14
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
15
+ pub struct ConfigFileResponse {
16
+ #[serde(rename = "contents", skip_serializing_if = "Option::is_none")]
17
+ pub contents: Option<String>,
18
+ #[serde(rename = "sha256", skip_serializing_if = "Option::is_none")]
19
+ pub sha256: Option<String>,
20
+ #[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")]
21
+ pub updated_at: Option<String>,
22
+ #[serde(rename = "updated_by_user_id", skip_serializing_if = "Option::is_none")]
23
+ pub updated_by_user_id: Option<String>,
24
+ }
25
+
26
+ impl ConfigFileResponse {
27
+ pub fn new(
28
+ contents: Option<String>,
29
+ sha256: Option<String>,
30
+ updated_at: Option<String>,
31
+ updated_by_user_id: Option<String>,
32
+ ) -> ConfigFileResponse {
33
+ ConfigFileResponse {
34
+ contents,
35
+ sha256,
36
+ updated_at,
37
+ updated_by_user_id,
38
+ }
39
+ }
40
+ }
codex-rs/codex-backend-openapi-models/src/models/credit_status_details.rs ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+ use serde::Deserialize;
11
+ use serde::Serialize;
12
+
13
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
14
+ pub struct CreditStatusDetails {
15
+ #[serde(rename = "has_credits")]
16
+ pub has_credits: bool,
17
+ #[serde(rename = "unlimited")]
18
+ pub unlimited: bool,
19
+ #[serde(
20
+ rename = "balance",
21
+ default,
22
+ with = "::serde_with::rust::double_option",
23
+ skip_serializing_if = "Option::is_none"
24
+ )]
25
+ pub balance: Option<Option<String>>,
26
+ #[serde(
27
+ rename = "approx_local_messages",
28
+ default,
29
+ with = "::serde_with::rust::double_option",
30
+ skip_serializing_if = "Option::is_none"
31
+ )]
32
+ pub approx_local_messages: Option<Option<Vec<serde_json::Value>>>,
33
+ #[serde(
34
+ rename = "approx_cloud_messages",
35
+ default,
36
+ with = "::serde_with::rust::double_option",
37
+ skip_serializing_if = "Option::is_none"
38
+ )]
39
+ pub approx_cloud_messages: Option<Option<Vec<serde_json::Value>>>,
40
+ }
41
+
42
+ impl CreditStatusDetails {
43
+ pub fn new(has_credits: bool, unlimited: bool) -> CreditStatusDetails {
44
+ CreditStatusDetails {
45
+ has_credits,
46
+ unlimited,
47
+ balance: None,
48
+ approx_local_messages: None,
49
+ approx_cloud_messages: None,
50
+ }
51
+ }
52
+ }
codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct DeliveredConfigToml {
17
+ #[serde(
18
+ rename = "enterprise_managed",
19
+ default,
20
+ with = "::serde_with::rust::double_option",
21
+ skip_serializing_if = "Option::is_none"
22
+ )]
23
+ pub enterprise_managed: Option<Option<Vec<models::DeliveredTomlFragment>>>,
24
+ #[serde(
25
+ rename = "managed_layers",
26
+ default,
27
+ with = "::serde_with::rust::double_option",
28
+ skip_serializing_if = "Option::is_none"
29
+ )]
30
+ pub managed_layers: Option<Option<Box<models::DeliveredManagedLayers>>>,
31
+ }
32
+
33
+ impl DeliveredConfigToml {
34
+ pub fn new() -> DeliveredConfigToml {
35
+ DeliveredConfigToml {
36
+ enterprise_managed: None,
37
+ managed_layers: None,
38
+ }
39
+ }
40
+ }
codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct DeliveredManagedLayers {
17
+ #[serde(rename = "baseline")]
18
+ pub baseline: Vec<models::DeliveredTomlFragment>,
19
+ #[serde(rename = "system_overlay")]
20
+ pub system_overlay: Vec<models::DeliveredTomlFragment>,
21
+ }
22
+
23
+ impl DeliveredManagedLayers {
24
+ pub fn new(
25
+ baseline: Vec<models::DeliveredTomlFragment>,
26
+ system_overlay: Vec<models::DeliveredTomlFragment>,
27
+ ) -> DeliveredManagedLayers {
28
+ DeliveredManagedLayers {
29
+ baseline,
30
+ system_overlay,
31
+ }
32
+ }
33
+ }
codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct DeliveredRequirementsToml {
17
+ #[serde(
18
+ rename = "enterprise_managed",
19
+ default,
20
+ with = "::serde_with::rust::double_option",
21
+ skip_serializing_if = "Option::is_none"
22
+ )]
23
+ pub enterprise_managed: Option<Option<Vec<models::DeliveredTomlFragment>>>,
24
+ #[serde(
25
+ rename = "managed_layers",
26
+ default,
27
+ with = "::serde_with::rust::double_option",
28
+ skip_serializing_if = "Option::is_none"
29
+ )]
30
+ pub managed_layers: Option<Option<Box<models::DeliveredManagedLayers>>>,
31
+ }
32
+
33
+ impl DeliveredRequirementsToml {
34
+ pub fn new() -> DeliveredRequirementsToml {
35
+ DeliveredRequirementsToml {
36
+ enterprise_managed: None,
37
+ managed_layers: None,
38
+ }
39
+ }
40
+ }
codex-rs/codex-backend-openapi-models/src/models/delivered_toml_fragment.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use serde::Deserialize;
12
+ use serde::Serialize;
13
+
14
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
15
+ pub struct DeliveredTomlFragment {
16
+ #[serde(rename = "id")]
17
+ pub id: String,
18
+ #[serde(rename = "name")]
19
+ pub name: String,
20
+ #[serde(rename = "contents")]
21
+ pub contents: String,
22
+ }
23
+
24
+ impl DeliveredTomlFragment {
25
+ pub fn new(id: String, name: String, contents: String) -> DeliveredTomlFragment {
26
+ DeliveredTomlFragment { id, name, contents }
27
+ }
28
+ }
codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct ExternalPullRequestResponse {
17
+ #[serde(rename = "id")]
18
+ pub id: String,
19
+ #[serde(rename = "assistant_turn_id")]
20
+ pub assistant_turn_id: String,
21
+ #[serde(rename = "pull_request")]
22
+ pub pull_request: Box<models::GitPullRequest>,
23
+ #[serde(rename = "codex_updated_sha", skip_serializing_if = "Option::is_none")]
24
+ pub codex_updated_sha: Option<String>,
25
+ }
26
+
27
+ impl ExternalPullRequestResponse {
28
+ pub fn new(
29
+ id: String,
30
+ assistant_turn_id: String,
31
+ pull_request: models::GitPullRequest,
32
+ ) -> ExternalPullRequestResponse {
33
+ ExternalPullRequestResponse {
34
+ id,
35
+ assistant_turn_id,
36
+ pull_request: Box::new(pull_request),
37
+ codex_updated_sha: None,
38
+ }
39
+ }
40
+ }
codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use serde::Deserialize;
12
+ use serde::Serialize;
13
+
14
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
15
+ pub struct GitPullRequest {
16
+ #[serde(rename = "number")]
17
+ pub number: i32,
18
+ #[serde(rename = "url")]
19
+ pub url: String,
20
+ #[serde(rename = "state")]
21
+ pub state: String,
22
+ #[serde(rename = "merged")]
23
+ pub merged: bool,
24
+ #[serde(rename = "mergeable")]
25
+ pub mergeable: bool,
26
+ #[serde(rename = "draft", skip_serializing_if = "Option::is_none")]
27
+ pub draft: Option<bool>,
28
+ #[serde(rename = "title", skip_serializing_if = "Option::is_none")]
29
+ pub title: Option<String>,
30
+ #[serde(rename = "body", skip_serializing_if = "Option::is_none")]
31
+ pub body: Option<String>,
32
+ #[serde(rename = "base", skip_serializing_if = "Option::is_none")]
33
+ pub base: Option<String>,
34
+ #[serde(rename = "head", skip_serializing_if = "Option::is_none")]
35
+ pub head: Option<String>,
36
+ #[serde(rename = "base_sha", skip_serializing_if = "Option::is_none")]
37
+ pub base_sha: Option<String>,
38
+ #[serde(rename = "head_sha", skip_serializing_if = "Option::is_none")]
39
+ pub head_sha: Option<String>,
40
+ #[serde(rename = "merge_commit_sha", skip_serializing_if = "Option::is_none")]
41
+ pub merge_commit_sha: Option<String>,
42
+ #[serde(rename = "comments", skip_serializing_if = "Option::is_none")]
43
+ pub comments: Option<serde_json::Value>,
44
+ #[serde(rename = "diff", skip_serializing_if = "Option::is_none")]
45
+ pub diff: Option<serde_json::Value>,
46
+ #[serde(rename = "user", skip_serializing_if = "Option::is_none")]
47
+ pub user: Option<serde_json::Value>,
48
+ }
49
+
50
+ impl GitPullRequest {
51
+ pub fn new(
52
+ number: i32,
53
+ url: String,
54
+ state: String,
55
+ merged: bool,
56
+ mergeable: bool,
57
+ ) -> GitPullRequest {
58
+ GitPullRequest {
59
+ number,
60
+ url,
61
+ state,
62
+ merged,
63
+ mergeable,
64
+ draft: None,
65
+ title: None,
66
+ body: None,
67
+ base: None,
68
+ head: None,
69
+ base_sha: None,
70
+ head_sha: None,
71
+ merge_commit_sha: None,
72
+ comments: None,
73
+ diff: None,
74
+ user: None,
75
+ }
76
+ }
77
+ }
codex-rs/codex-backend-openapi-models/src/models/mod.rs ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pub mod analytics;
2
+ // Curated minimal export list for current workspace usage.
3
+ // NOTE: This file was previously auto-generated by the OpenAPI generator.
4
+ // Currently export only the types referenced by the workspace
5
+ // The process for this will change
6
+
7
+ // Config
8
+ pub(crate) mod config_bundle_response;
9
+ pub use self::config_bundle_response::ConfigBundleResponse;
10
+
11
+ pub(crate) mod config_file_response;
12
+ pub use self::config_file_response::ConfigFileResponse;
13
+
14
+ pub(crate) mod delivered_config_toml;
15
+ pub use self::delivered_config_toml::DeliveredConfigToml;
16
+
17
+ pub(crate) mod delivered_managed_layers;
18
+ pub use self::delivered_managed_layers::DeliveredManagedLayers;
19
+
20
+ pub(crate) mod delivered_requirements_toml;
21
+ pub use self::delivered_requirements_toml::DeliveredRequirementsToml;
22
+
23
+ pub(crate) mod delivered_toml_fragment;
24
+ pub use self::delivered_toml_fragment::DeliveredTomlFragment;
25
+
26
+ // Cloud Tasks
27
+ pub(crate) mod code_task_details_response;
28
+ pub use self::code_task_details_response::CodeTaskDetailsResponse;
29
+
30
+ pub(crate) mod task_response;
31
+ pub use self::task_response::TaskResponse;
32
+
33
+ pub(crate) mod external_pull_request_response;
34
+ pub use self::external_pull_request_response::ExternalPullRequestResponse;
35
+
36
+ pub(crate) mod git_pull_request;
37
+ pub use self::git_pull_request::GitPullRequest;
38
+
39
+ pub(crate) mod task_list_item;
40
+ pub use self::task_list_item::TaskListItem;
41
+
42
+ pub(crate) mod paginated_list_task_list_item_;
43
+ pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem;
44
+
45
+ // Rate Limits
46
+ pub(crate) mod additional_rate_limit_details;
47
+ pub use self::additional_rate_limit_details::AdditionalRateLimitDetails;
48
+
49
+ pub(crate) mod rate_limit_status_payload;
50
+ pub use self::rate_limit_status_payload::PlanType;
51
+ pub use self::rate_limit_status_payload::RateLimitReachedKind;
52
+ pub use self::rate_limit_status_payload::RateLimitReachedType;
53
+ pub use self::rate_limit_status_payload::RateLimitStatusPayload;
54
+
55
+ pub(crate) mod rate_limit_status_details;
56
+ pub use self::rate_limit_status_details::RateLimitStatusDetails;
57
+
58
+ pub(crate) mod rate_limit_window_snapshot;
59
+ pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot;
60
+
61
+ pub(crate) mod credit_status_details;
62
+ pub use self::credit_status_details::CreditStatusDetails;
63
+
64
+ pub(crate) mod spend_control_limit_details;
65
+ pub use self::spend_control_limit_details::SpendControlLimitDetails;
66
+
67
+ pub(crate) mod spend_control_status_details;
68
+ pub use self::spend_control_status_details::SpendControlStatusDetails;
codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct PaginatedListTaskListItem {
17
+ #[serde(rename = "items")]
18
+ pub items: Vec<models::TaskListItem>,
19
+ #[serde(rename = "cursor", skip_serializing_if = "Option::is_none")]
20
+ pub cursor: Option<String>,
21
+ }
22
+
23
+ impl PaginatedListTaskListItem {
24
+ pub fn new(items: Vec<models::TaskListItem>) -> PaginatedListTaskListItem {
25
+ PaginatedListTaskListItem {
26
+ items,
27
+ cursor: None,
28
+ }
29
+ }
30
+ }
codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct RateLimitStatusDetails {
17
+ #[serde(rename = "allowed")]
18
+ pub allowed: bool,
19
+ #[serde(rename = "limit_reached")]
20
+ pub limit_reached: bool,
21
+ #[serde(
22
+ rename = "primary_window",
23
+ default,
24
+ with = "::serde_with::rust::double_option",
25
+ skip_serializing_if = "Option::is_none"
26
+ )]
27
+ pub primary_window: Option<Option<Box<models::RateLimitWindowSnapshot>>>,
28
+ #[serde(
29
+ rename = "secondary_window",
30
+ default,
31
+ with = "::serde_with::rust::double_option",
32
+ skip_serializing_if = "Option::is_none"
33
+ )]
34
+ pub secondary_window: Option<Option<Box<models::RateLimitWindowSnapshot>>>,
35
+ }
36
+
37
+ impl RateLimitStatusDetails {
38
+ pub fn new(allowed: bool, limit_reached: bool) -> RateLimitStatusDetails {
39
+ RateLimitStatusDetails {
40
+ allowed,
41
+ limit_reached,
42
+ primary_window: None,
43
+ secondary_window: None,
44
+ }
45
+ }
46
+ }
codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct RateLimitStatusPayload {
17
+ #[serde(rename = "plan_type")]
18
+ pub plan_type: PlanType,
19
+ #[serde(
20
+ rename = "rate_limit",
21
+ default,
22
+ with = "::serde_with::rust::double_option",
23
+ skip_serializing_if = "Option::is_none"
24
+ )]
25
+ pub rate_limit: Option<Option<Box<models::RateLimitStatusDetails>>>,
26
+ #[serde(
27
+ rename = "credits",
28
+ default,
29
+ with = "::serde_with::rust::double_option",
30
+ skip_serializing_if = "Option::is_none"
31
+ )]
32
+ pub credits: Option<Option<Box<models::CreditStatusDetails>>>,
33
+ #[serde(
34
+ rename = "spend_control",
35
+ default,
36
+ with = "::serde_with::rust::double_option",
37
+ skip_serializing_if = "Option::is_none"
38
+ )]
39
+ pub spend_control: Option<Option<Box<models::SpendControlStatusDetails>>>,
40
+ #[serde(
41
+ rename = "additional_rate_limits",
42
+ default,
43
+ with = "::serde_with::rust::double_option",
44
+ skip_serializing_if = "Option::is_none"
45
+ )]
46
+ pub additional_rate_limits: Option<Option<Vec<models::AdditionalRateLimitDetails>>>,
47
+ #[serde(
48
+ rename = "rate_limit_reached_type",
49
+ default,
50
+ with = "::serde_with::rust::double_option",
51
+ skip_serializing_if = "Option::is_none"
52
+ )]
53
+ pub rate_limit_reached_type: Option<Option<RateLimitReachedType>>,
54
+ }
55
+
56
+ impl RateLimitStatusPayload {
57
+ pub fn new(plan_type: PlanType) -> RateLimitStatusPayload {
58
+ RateLimitStatusPayload {
59
+ plan_type,
60
+ rate_limit: None,
61
+ credits: None,
62
+ spend_control: None,
63
+ additional_rate_limits: None,
64
+ rate_limit_reached_type: None,
65
+ }
66
+ }
67
+ }
68
+
69
+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70
+ pub struct RateLimitReachedType {
71
+ #[serde(rename = "type")]
72
+ pub kind: RateLimitReachedKind,
73
+ }
74
+
75
+ #[derive(
76
+ Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default,
77
+ )]
78
+ pub enum RateLimitReachedKind {
79
+ #[serde(rename = "rate_limit_reached")]
80
+ RateLimitReached,
81
+ #[serde(rename = "workspace_owner_credits_depleted")]
82
+ WorkspaceOwnerCreditsDepleted,
83
+ #[serde(rename = "workspace_member_credits_depleted")]
84
+ WorkspaceMemberCreditsDepleted,
85
+ #[serde(rename = "workspace_owner_usage_limit_reached")]
86
+ WorkspaceOwnerUsageLimitReached,
87
+ #[serde(rename = "workspace_member_usage_limit_reached")]
88
+ WorkspaceMemberUsageLimitReached,
89
+ #[serde(rename = "unknown", other)]
90
+ #[default]
91
+ Unknown,
92
+ }
93
+
94
+ #[derive(
95
+ Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default,
96
+ )]
97
+ pub enum PlanType {
98
+ #[serde(rename = "guest")]
99
+ #[default]
100
+ Guest,
101
+ #[serde(rename = "free")]
102
+ Free,
103
+ #[serde(rename = "go")]
104
+ Go,
105
+ #[serde(rename = "plus")]
106
+ Plus,
107
+ #[serde(rename = "pro")]
108
+ Pro,
109
+ #[serde(rename = "prolite")]
110
+ ProLite,
111
+ #[serde(rename = "free_workspace")]
112
+ FreeWorkspace,
113
+ #[serde(rename = "team")]
114
+ Team,
115
+ #[serde(rename = "self_serve_business_prolite")]
116
+ SelfServeBusinessProLite,
117
+ #[serde(rename = "self_serve_business_usage_based")]
118
+ SelfServeBusinessUsageBased,
119
+ #[serde(rename = "business")]
120
+ Business,
121
+ #[serde(rename = "ent26")]
122
+ Ent26,
123
+ #[serde(rename = "enterprise_cbp_automation")]
124
+ EnterpriseCbpAutomation,
125
+ #[serde(rename = "enterprise_cbp_usage_based")]
126
+ EnterpriseCbpUsageBased,
127
+ #[serde(rename = "education")]
128
+ Education,
129
+ #[serde(rename = "quorum")]
130
+ Quorum,
131
+ #[serde(rename = "k12")]
132
+ K12,
133
+ #[serde(rename = "enterprise")]
134
+ Enterprise,
135
+ #[serde(rename = "edu")]
136
+ Edu,
137
+ #[serde(rename = "edu_plus")]
138
+ EduPlus,
139
+ #[serde(rename = "edu_pro")]
140
+ EduPro,
141
+ #[serde(rename = "unknown", other)]
142
+ Unknown,
143
+ }
codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+ use serde::Deserialize;
11
+ use serde::Serialize;
12
+
13
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
14
+ pub struct RateLimitWindowSnapshot {
15
+ #[serde(rename = "used_percent")]
16
+ pub used_percent: i32,
17
+ #[serde(rename = "limit_window_seconds")]
18
+ pub limit_window_seconds: i32,
19
+ #[serde(rename = "reset_after_seconds")]
20
+ pub reset_after_seconds: i32,
21
+ #[serde(rename = "reset_at")]
22
+ pub reset_at: i32,
23
+ }
24
+
25
+ impl RateLimitWindowSnapshot {
26
+ pub fn new(
27
+ used_percent: i32,
28
+ limit_window_seconds: i32,
29
+ reset_after_seconds: i32,
30
+ reset_at: i32,
31
+ ) -> RateLimitWindowSnapshot {
32
+ RateLimitWindowSnapshot {
33
+ used_percent,
34
+ limit_window_seconds,
35
+ reset_after_seconds,
36
+ reset_at,
37
+ }
38
+ }
39
+ }
codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use serde::Deserialize;
12
+ use serde::Serialize;
13
+
14
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
15
+ pub struct SpendControlLimitDetails {
16
+ #[serde(
17
+ rename = "source",
18
+ default,
19
+ with = "::serde_with::rust::double_option",
20
+ skip_serializing_if = "Option::is_none"
21
+ )]
22
+ pub source: Option<Option<String>>,
23
+ #[serde(rename = "limit")]
24
+ pub limit: String,
25
+ #[serde(rename = "used")]
26
+ pub used: String,
27
+ #[serde(rename = "remaining")]
28
+ pub remaining: String,
29
+ #[serde(rename = "used_percent")]
30
+ pub used_percent: i32,
31
+ #[serde(rename = "remaining_percent")]
32
+ pub remaining_percent: i32,
33
+ #[serde(rename = "reset_after_seconds")]
34
+ pub reset_after_seconds: i32,
35
+ #[serde(rename = "reset_at")]
36
+ pub reset_at: i32,
37
+ }
38
+
39
+ impl SpendControlLimitDetails {
40
+ pub fn new(
41
+ limit: String,
42
+ used: String,
43
+ remaining: String,
44
+ used_percent: i32,
45
+ remaining_percent: i32,
46
+ reset_after_seconds: i32,
47
+ reset_at: i32,
48
+ ) -> SpendControlLimitDetails {
49
+ SpendControlLimitDetails {
50
+ source: None,
51
+ limit,
52
+ used,
53
+ remaining,
54
+ used_percent,
55
+ remaining_percent,
56
+ reset_after_seconds,
57
+ reset_at,
58
+ }
59
+ }
60
+ }
codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct SpendControlStatusDetails {
17
+ #[serde(rename = "reached")]
18
+ pub reached: bool,
19
+ #[serde(
20
+ rename = "individual_limit",
21
+ default,
22
+ with = "::serde_with::rust::double_option",
23
+ skip_serializing_if = "Option::is_none"
24
+ )]
25
+ pub individual_limit: Option<Option<Box<models::SpendControlLimitDetails>>>,
26
+ }
27
+
28
+ impl SpendControlStatusDetails {
29
+ pub fn new(reached: bool) -> SpendControlStatusDetails {
30
+ SpendControlStatusDetails {
31
+ reached,
32
+ individual_limit: None,
33
+ }
34
+ }
35
+ }
codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct TaskListItem {
17
+ #[serde(rename = "id")]
18
+ pub id: String,
19
+ #[serde(rename = "title")]
20
+ pub title: String,
21
+ #[serde(
22
+ rename = "has_generated_title",
23
+ skip_serializing_if = "Option::is_none"
24
+ )]
25
+ pub has_generated_title: Option<bool>,
26
+ #[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")]
27
+ pub updated_at: Option<f64>,
28
+ #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")]
29
+ pub created_at: Option<f64>,
30
+ #[serde(
31
+ rename = "task_status_display",
32
+ skip_serializing_if = "Option::is_none"
33
+ )]
34
+ pub task_status_display: Option<std::collections::HashMap<String, serde_json::Value>>,
35
+ #[serde(rename = "archived")]
36
+ pub archived: bool,
37
+ #[serde(rename = "has_unread_turn")]
38
+ pub has_unread_turn: bool,
39
+ #[serde(rename = "pull_requests", skip_serializing_if = "Option::is_none")]
40
+ pub pull_requests: Option<Vec<models::ExternalPullRequestResponse>>,
41
+ }
42
+
43
+ impl TaskListItem {
44
+ pub fn new(
45
+ id: String,
46
+ title: String,
47
+ has_generated_title: Option<bool>,
48
+ archived: bool,
49
+ has_unread_turn: bool,
50
+ ) -> TaskListItem {
51
+ TaskListItem {
52
+ id,
53
+ title,
54
+ has_generated_title,
55
+ updated_at: None,
56
+ created_at: None,
57
+ task_status_display: None,
58
+ archived,
59
+ has_unread_turn,
60
+ pull_requests: None,
61
+ }
62
+ }
63
+ }
codex-rs/codex-backend-openapi-models/src/models/task_response.rs ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * codex-backend
3
+ *
4
+ * codex-backend
5
+ *
6
+ * The version of the OpenAPI document: 0.0.1
7
+ *
8
+ * Generated by: https://openapi-generator.tech
9
+ */
10
+
11
+ use crate::models;
12
+ use serde::Deserialize;
13
+ use serde::Serialize;
14
+
15
+ #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
16
+ pub struct TaskResponse {
17
+ #[serde(rename = "id")]
18
+ pub id: String,
19
+ #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")]
20
+ pub created_at: Option<f64>,
21
+ #[serde(rename = "title")]
22
+ pub title: String,
23
+ #[serde(
24
+ rename = "has_generated_title",
25
+ skip_serializing_if = "Option::is_none"
26
+ )]
27
+ pub has_generated_title: Option<bool>,
28
+ #[serde(rename = "current_turn_id", skip_serializing_if = "Option::is_none")]
29
+ pub current_turn_id: Option<String>,
30
+ #[serde(rename = "has_unread_turn", skip_serializing_if = "Option::is_none")]
31
+ pub has_unread_turn: Option<bool>,
32
+ #[serde(
33
+ rename = "denormalized_metadata",
34
+ skip_serializing_if = "Option::is_none"
35
+ )]
36
+ pub denormalized_metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
37
+ #[serde(rename = "archived")]
38
+ pub archived: bool,
39
+ #[serde(rename = "external_pull_requests")]
40
+ pub external_pull_requests: Vec<models::ExternalPullRequestResponse>,
41
+ }
42
+
43
+ impl TaskResponse {
44
+ pub fn new(
45
+ id: String,
46
+ title: String,
47
+ archived: bool,
48
+ external_pull_requests: Vec<models::ExternalPullRequestResponse>,
49
+ ) -> TaskResponse {
50
+ TaskResponse {
51
+ id,
52
+ created_at: None,
53
+ title,
54
+ has_generated_title: None,
55
+ current_turn_id: None,
56
+ has_unread_turn: None,
57
+ denormalized_metadata: None,
58
+ archived,
59
+ external_pull_requests,
60
+ }
61
+ }
62
+ }
codex-rs/config-schema/src/main.rs ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Generates the canonical config schema fixture for development and releases.
2
+
3
+ use anyhow::Result;
4
+ use clap::Parser;
5
+ use std::path::PathBuf;
6
+
7
+ /// Generate the JSON Schema for `config.toml` and write it to `config.schema.json`.
8
+ #[derive(Parser)]
9
+ #[command(name = "codex-write-config-schema")]
10
+ struct Args {
11
+ #[arg(short, long, value_name = "PATH")]
12
+ out: Option<PathBuf>,
13
+ }
14
+
15
+ fn main() -> Result<()> {
16
+ let args = Args::parse();
17
+ let out_path = args.out.unwrap_or_else(|| {
18
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../core/config.schema.json")
19
+ });
20
+ codex_config::schema::write_config_schema(&out_path)?;
21
+ Ok(())
22
+ }
codex-rs/config/src/application_requirements.rs ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Managed application destinations are independent of agent network permissions.
2
+ //! Enabled policies deny unlisted domains and use normal managed TOML precedence.
3
+
4
+ use crate::NetworkDomainPermissionToml;
5
+ use serde::Deserialize;
6
+ use serde::de::Error;
7
+ use std::collections::BTreeMap;
8
+
9
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
10
+ #[serde(deny_unknown_fields)]
11
+ pub struct ApplicationRequirementsToml {
12
+ pub network: Option<ApplicationNetworkRequirementsToml>,
13
+ }
14
+
15
+ #[derive(Debug, Clone, PartialEq, Eq)]
16
+ pub struct ApplicationNetworkRequirementsToml {
17
+ pub enabled: bool,
18
+ pub domains: BTreeMap<String, NetworkDomainPermissionToml>,
19
+ }
20
+
21
+ impl<'de> Deserialize<'de> for ApplicationNetworkRequirementsToml {
22
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
23
+ where
24
+ D: serde::Deserializer<'de>,
25
+ {
26
+ #[derive(Deserialize)]
27
+ #[serde(deny_unknown_fields)]
28
+ struct Network {
29
+ enabled: Option<bool>,
30
+ #[serde(default)]
31
+ domains: BTreeMap<String, NetworkDomainPermissionToml>,
32
+ }
33
+
34
+ let raw = Network::deserialize(deserializer)?;
35
+ let mut domains = BTreeMap::new();
36
+ for (domain, permission) in raw.domains {
37
+ let normalized = domain
38
+ .strip_suffix('.')
39
+ .unwrap_or(&domain)
40
+ .to_ascii_lowercase();
41
+ if normalized.len() > 253
42
+ || normalized.split('.').any(|label| {
43
+ label.is_empty()
44
+ || label.len() > 63
45
+ || !label.starts_with(|c: char| c.is_ascii_alphanumeric())
46
+ || !label.ends_with(|c: char| c.is_ascii_alphanumeric())
47
+ || !label
48
+ .bytes()
49
+ .all(|c| c.is_ascii_alphanumeric() || c == b'-')
50
+ })
51
+ {
52
+ return Err(D::Error::custom(
53
+ "application.network.domains requires exact ASCII domain names without URLs, ports, or wildcards",
54
+ ));
55
+ }
56
+ if domains.insert(normalized, permission).is_some() {
57
+ return Err(D::Error::custom(format!(
58
+ "duplicate application.network domain after normalization: {domain:?}"
59
+ )));
60
+ }
61
+ }
62
+ Ok(Self {
63
+ enabled: raw.enabled.unwrap_or(true),
64
+ domains,
65
+ })
66
+ }
67
+ }
68
+
69
+ #[cfg(test)]
70
+ #[path = "application_requirements_tests.rs"]
71
+ mod tests;
codex-rs/config/src/bedrock_runtime_tests.rs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_model_provider_info::AMAZON_BEDROCK_RUNTIME_PROVIDER_ID;
2
+ use codex_model_provider_info::ModelProviderAwsAuthInfo;
3
+ use pretty_assertions::assert_eq;
4
+
5
+ use super::ConfigToml;
6
+
7
+ #[test]
8
+ fn runtime_provider_accepts_aws_profile_and_region_overrides() {
9
+ let config = toml::from_str::<ConfigToml>(
10
+ r#"
11
+ [model_providers.amazon-bedrock-runtime.aws]
12
+ profile = "runtime-profile"
13
+ region = "us-west-2"
14
+ "#,
15
+ )
16
+ .expect("Bedrock Runtime AWS overrides should deserialize");
17
+
18
+ assert_eq!(
19
+ config
20
+ .model_providers
21
+ .get(AMAZON_BEDROCK_RUNTIME_PROVIDER_ID)
22
+ .and_then(|provider| provider.aws.clone()),
23
+ Some(ModelProviderAwsAuthInfo {
24
+ profile: Some("runtime-profile".to_string()),
25
+ region: Some("us-west-2".to_string()),
26
+ credential_export: None,
27
+ auth_refresh: None,
28
+ })
29
+ );
30
+ }
31
+
32
+ #[test]
33
+ fn custom_provider_still_rejects_aws_auth() {
34
+ let error = toml::from_str::<ConfigToml>(
35
+ r#"
36
+ [model_providers.custom]
37
+ name = "Custom"
38
+
39
+ [model_providers.custom.aws]
40
+ region = "us-west-2"
41
+ "#,
42
+ )
43
+ .expect_err("custom providers must not accept AWS auth");
44
+
45
+ assert!(error.to_string().contains(
46
+ "provider aws is only supported for `amazon-bedrock` or `amazon-bedrock-runtime`"
47
+ ));
48
+ }
codex-rs/config/src/browser_computer_use_requirements_tests.rs ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::BrowserUseRequirementsToml;
2
+ use crate::ConfigRequirementsToml;
3
+ use pretty_assertions::assert_eq;
4
+
5
+ #[test]
6
+ fn webmcp_requirements_preserve_explicit_values_and_omission() {
7
+ for (contents, expected_browser_use, expected_empty) in [
8
+ ("", None, true),
9
+ ("[browser_use]", Some(None), true),
10
+ (
11
+ "[browser_use]\nallow_webmcp = true",
12
+ Some(Some(true)),
13
+ false,
14
+ ),
15
+ (
16
+ "[browser_use]\nallow_webmcp = false",
17
+ Some(Some(false)),
18
+ false,
19
+ ),
20
+ ] {
21
+ let requirements: ConfigRequirementsToml =
22
+ toml::from_str(contents).expect("parse managed WebMCP policy");
23
+ assert_eq!(
24
+ requirements,
25
+ ConfigRequirementsToml {
26
+ browser_use: expected_browser_use.map(|allow_webmcp| BrowserUseRequirementsToml {
27
+ allow_webmcp,
28
+ ..Default::default()
29
+ }),
30
+ ..Default::default()
31
+ },
32
+ );
33
+ assert_eq!(requirements.is_empty(), expected_empty, "{contents}");
34
+ }
35
+ }
36
+
37
+ #[test]
38
+ fn webmcp_requirements_reject_non_booleans() {
39
+ for value in ["\"true\"", "1", "[]"] {
40
+ let contents = format!("[browser_use]\nallow_webmcp = {value}");
41
+ let error = toml::from_str::<ConfigRequirementsToml>(&contents)
42
+ .expect_err("WebMCP policy must be a boolean");
43
+ assert!(error.to_string().contains("allow_webmcp"));
44
+ }
45
+ }
codex-rs/config/src/browser_use.rs ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::AllowDenyRequirementToml;
2
+ use schemars::JsonSchema;
3
+ use serde::Deserialize;
4
+ use serde::Serialize;
5
+ use std::collections::BTreeMap;
6
+
7
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
8
+ #[schemars(deny_unknown_fields)]
9
+ pub struct BrowserUseConfigToml {
10
+ pub allow_history_access: Option<bool>,
11
+ pub default_origin_policy: Option<BrowserUseOriginPolicyConfigToml>,
12
+ pub origins: Option<BTreeMap<String, BrowserUseOriginPolicyConfigToml>>,
13
+ }
14
+
15
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
16
+ #[schemars(deny_unknown_fields)]
17
+ pub struct BrowserUseOriginPolicyConfigToml {
18
+ pub access: Option<AllowDenyRequirementToml>,
19
+ pub downloads: Option<AllowDenyRequirementToml>,
20
+ pub uploads: Option<AllowDenyRequirementToml>,
21
+ pub full_cdp_access: Option<AllowDenyRequirementToml>,
22
+ }
23
+
24
+ #[cfg(test)]
25
+ #[path = "browser_use_tests.rs"]
26
+ mod tests;
codex-rs/config/src/cloud_config_bundle.rs ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Cloud config bundle domain model and shared in-memory loader.
2
+ //!
3
+ //! The backend bundle groups cloud-delivered config and requirements fragments
4
+ //! by source bucket. `CloudConfigBundleLayers` converts those raw buckets into
5
+ //! layer entries while preserving each bucket's insertion semantics.
6
+
7
+ use crate::CloudConfigFragment;
8
+ use crate::ConfigLayerEntry;
9
+ use crate::RequirementSource;
10
+ use crate::RequirementsLayerEntry;
11
+ use crate::cloud_config_layers::CloudConfigLayerError;
12
+ use crate::cloud_config_layers::cloud_config_layers_from_fragments_strict;
13
+ use crate::cloud_config_layers_from_fragments;
14
+ use codex_utils_absolute_path::AbsolutePathBuf;
15
+ use futures::future::BoxFuture;
16
+ use futures::future::FutureExt;
17
+ use serde::Deserialize;
18
+ use serde::Serialize;
19
+ use std::fmt;
20
+ use std::future::Future;
21
+ use std::sync::Arc;
22
+ use thiserror::Error;
23
+
24
+ #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
25
+ pub struct CloudConfigBundle {
26
+ pub config_toml: CloudConfigTomlBundle,
27
+ pub requirements_toml: CloudRequirementsTomlBundle,
28
+ }
29
+
30
+ impl CloudConfigBundle {
31
+ pub fn is_empty(&self) -> bool {
32
+ let CloudConfigBundle {
33
+ config_toml,
34
+ requirements_toml,
35
+ } = self;
36
+ let CloudConfigTomlBundle {
37
+ enterprise_managed: config_enterprise_managed,
38
+ } = config_toml;
39
+ let CloudRequirementsTomlBundle {
40
+ enterprise_managed: requirements_enterprise_managed,
41
+ } = requirements_toml;
42
+
43
+ config_enterprise_managed.is_empty() && requirements_enterprise_managed.is_empty()
44
+ }
45
+ }
46
+
47
+ #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
48
+ pub struct CloudConfigTomlBundle {
49
+ pub enterprise_managed: Vec<CloudConfigFragment>,
50
+ }
51
+
52
+ #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
53
+ pub struct CloudRequirementsTomlBundle {
54
+ pub enterprise_managed: Vec<CloudRequirementsFragment>,
55
+ }
56
+
57
+ impl CloudRequirementsTomlBundle {
58
+ pub(crate) fn into_layers(self, base_dir: &AbsolutePathBuf) -> Vec<RequirementsLayerEntry> {
59
+ let Self { enterprise_managed } = self;
60
+ let mut layers = enterprise_managed
61
+ .into_iter()
62
+ .map(|fragment| {
63
+ RequirementsLayerEntry::from_toml(
64
+ RequirementSource::EnterpriseManaged {
65
+ id: fragment.id,
66
+ name: fragment.name,
67
+ },
68
+ fragment.contents,
69
+ )
70
+ .with_base_dir(base_dir.clone())
71
+ })
72
+ .collect::<Vec<_>>();
73
+ // Bundle fragments arrive highest-priority first, while requirements
74
+ // layers are merged lowest-priority to highest-priority.
75
+ layers.reverse();
76
+ layers
77
+ }
78
+ }
79
+
80
+ #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81
+ pub struct CloudRequirementsFragment {
82
+ pub id: String,
83
+ pub name: String,
84
+ pub contents: String,
85
+ }
86
+
87
+ /// Cloud config bundle converted into semantic layer buckets.
88
+ ///
89
+ /// This is not a final config stack. Callers still decide where each bucket is
90
+ /// inserted relative to local/system/user layers.
91
+ #[derive(Clone, Debug)]
92
+ pub struct CloudConfigBundleLayers {
93
+ /// Enterprise-managed config layers in `ConfigLayerStack` order.
94
+ pub enterprise_managed_config: Vec<ConfigLayerEntry>,
95
+ /// Enterprise-managed requirements layers in requirements layer merge order.
96
+ pub enterprise_managed_requirements: Vec<RequirementsLayerEntry>,
97
+ }
98
+
99
+ impl CloudConfigBundleLayers {
100
+ pub fn from_bundle(
101
+ bundle: CloudConfigBundle,
102
+ base_dir: &AbsolutePathBuf,
103
+ ) -> Result<Self, CloudConfigLayerError> {
104
+ Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ false)
105
+ }
106
+
107
+ pub fn from_bundle_strict_config(
108
+ bundle: CloudConfigBundle,
109
+ base_dir: &AbsolutePathBuf,
110
+ ) -> Result<Self, CloudConfigLayerError> {
111
+ Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ true)
112
+ }
113
+
114
+ fn from_bundle_impl(
115
+ bundle: CloudConfigBundle,
116
+ base_dir: &AbsolutePathBuf,
117
+ strict_config: bool,
118
+ ) -> Result<Self, CloudConfigLayerError> {
119
+ // Keep this destructuring exhaustive so adding a new bundle bucket forces
120
+ // an explicit choice about how it becomes layer data.
121
+ let CloudConfigBundle {
122
+ config_toml:
123
+ CloudConfigTomlBundle {
124
+ enterprise_managed: config_enterprise_managed,
125
+ },
126
+ requirements_toml,
127
+ } = bundle;
128
+
129
+ let enterprise_managed_config = if strict_config {
130
+ cloud_config_layers_from_fragments_strict(config_enterprise_managed, base_dir)?
131
+ } else {
132
+ cloud_config_layers_from_fragments(config_enterprise_managed, base_dir)?
133
+ };
134
+
135
+ let enterprise_managed_requirements = requirements_toml.into_layers(base_dir);
136
+
137
+ Ok(Self {
138
+ enterprise_managed_config,
139
+ enterprise_managed_requirements,
140
+ })
141
+ }
142
+ }
143
+
144
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
145
+ pub enum CloudConfigBundleLoadErrorCode {
146
+ Auth,
147
+ Timeout,
148
+ RequestFailed,
149
+ InvalidBundle,
150
+ Internal,
151
+ }
152
+
153
+ #[derive(Clone, Debug, Eq, Error, PartialEq)]
154
+ #[error("{message}")]
155
+ pub struct CloudConfigBundleLoadError {
156
+ code: CloudConfigBundleLoadErrorCode,
157
+ message: String,
158
+ status_code: Option<u16>,
159
+ }
160
+
161
+ impl CloudConfigBundleLoadError {
162
+ pub fn new(
163
+ code: CloudConfigBundleLoadErrorCode,
164
+ status_code: Option<u16>,
165
+ message: impl Into<String>,
166
+ ) -> Self {
167
+ Self {
168
+ code,
169
+ message: message.into(),
170
+ status_code,
171
+ }
172
+ }
173
+
174
+ pub fn code(&self) -> CloudConfigBundleLoadErrorCode {
175
+ self.code
176
+ }
177
+
178
+ pub fn status_code(&self) -> Option<u16> {
179
+ self.status_code
180
+ }
181
+ }
182
+
183
+ #[derive(Clone)]
184
+ pub struct CloudConfigBundleLoader {
185
+ getter: Arc<
186
+ dyn Fn()
187
+ -> BoxFuture<'static, Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>
188
+ + Send
189
+ + Sync,
190
+ >,
191
+ }
192
+
193
+ impl CloudConfigBundleLoader {
194
+ pub fn new<F>(fut: F) -> Self
195
+ where
196
+ F: Future<Output = Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>
197
+ + Send
198
+ + 'static,
199
+ {
200
+ let fut = fut.boxed().shared();
201
+ Self::from_getter(move || fut.clone())
202
+ }
203
+
204
+ /// Creates a loader that requests the latest bundle on every call.
205
+ pub fn from_getter<F, Fut>(getter: F) -> Self
206
+ where
207
+ F: Fn() -> Fut + Send + Sync + 'static,
208
+ Fut: Future<Output = Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>
209
+ + Send
210
+ + 'static,
211
+ {
212
+ Self {
213
+ getter: Arc::new(move || getter().boxed()),
214
+ }
215
+ }
216
+
217
+ /// Returns the current bundle snapshot.
218
+ pub async fn get(&self) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
219
+ (self.getter)().await
220
+ }
221
+ }
222
+
223
+ impl fmt::Debug for CloudConfigBundleLoader {
224
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225
+ f.debug_struct("CloudConfigBundleLoader").finish()
226
+ }
227
+ }
228
+
229
+ impl Default for CloudConfigBundleLoader {
230
+ fn default() -> Self {
231
+ Self::new(async { Ok(None) })
232
+ }
233
+ }
234
+
235
+ #[cfg(test)]
236
+ #[path = "cloud_config_bundle_tests.rs"]
237
+ mod tests;
codex-rs/config/src/cloud_config_layers_tests.rs ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::CONFIG_TOML_FILE;
3
+ use crate::ConfigLayerStack;
4
+ use crate::ConfigRequirements;
5
+ use crate::ConfigRequirementsToml;
6
+ use crate::config_toml::ConfigToml;
7
+ use crate::first_layer_config_error_from_entries;
8
+ use codex_utils_absolute_path::AbsolutePathBuf;
9
+ use codex_utils_absolute_path::test_support::PathBufExt;
10
+ use codex_utils_absolute_path::test_support::test_path_buf;
11
+ use pretty_assertions::assert_eq;
12
+ use std::path::Path;
13
+
14
+ fn fragment(id: &str, name: &str, contents: &str) -> CloudConfigFragment {
15
+ CloudConfigFragment {
16
+ id: id.to_string(),
17
+ name: name.to_string(),
18
+ contents: contents.to_string(),
19
+ }
20
+ }
21
+
22
+ fn toml(contents: &str) -> TomlValue {
23
+ toml::from_str(contents).expect("test TOML should parse")
24
+ }
25
+
26
+ fn base_dir() -> AbsolutePathBuf {
27
+ test_path_buf("/var/lib/codex").abs()
28
+ }
29
+
30
+ #[test]
31
+ fn layers_are_returned_in_stack_order() {
32
+ let base_dir = base_dir();
33
+ let layers = cloud_config_layers_from_fragments(
34
+ vec![
35
+ fragment("high", "High priority", "model = \"cloud-high\""),
36
+ fragment("low", "Low priority", "model_provider = \"cloud-low\""),
37
+ ],
38
+ &base_dir,
39
+ )
40
+ .expect("cloud config layers should compose");
41
+
42
+ assert_eq!(
43
+ layers
44
+ .iter()
45
+ .map(|layer| layer.name.clone())
46
+ .collect::<Vec<_>>(),
47
+ vec![
48
+ ConfigLayerSource::EnterpriseManaged {
49
+ id: "low".to_string(),
50
+ name: "Low priority".to_string(),
51
+ },
52
+ ConfigLayerSource::EnterpriseManaged {
53
+ id: "high".to_string(),
54
+ name: "High priority".to_string(),
55
+ },
56
+ ]
57
+ );
58
+ }
59
+
60
+ #[test]
61
+ fn strict_layers_reject_unknown_config_fields() {
62
+ let base_dir = base_dir();
63
+ let err = cloud_config_layers_from_fragments_strict(
64
+ vec![fragment("strict", "Strict layer", "unknown_key = true")],
65
+ &base_dir,
66
+ )
67
+ .expect_err("strict config should reject unknown fields");
68
+
69
+ assert_eq!(
70
+ err,
71
+ CloudConfigLayerError::Invalid {
72
+ fragment: CloudConfigFragmentSource {
73
+ id: "strict".to_string(),
74
+ name: "Strict layer".to_string(),
75
+ },
76
+ message: "unknown configuration field `unknown_key`".to_string(),
77
+ }
78
+ );
79
+ }
80
+
81
+ #[test]
82
+ fn enterprise_layers_precede_user_and_override_system() {
83
+ let base_dir = base_dir();
84
+ let mut layers = vec![ConfigLayerEntry::new(
85
+ ConfigLayerSource::System {
86
+ file: test_path_buf("/etc/codex/config.toml").abs(),
87
+ },
88
+ toml(
89
+ r#"
90
+ model = "system"
91
+ model_provider = "system"
92
+ review_model = "system-review"
93
+ "#,
94
+ ),
95
+ )];
96
+ layers.extend(
97
+ cloud_config_layers_from_fragments(
98
+ vec![
99
+ fragment("high", "High priority", "model_provider = \"cloud-high\""),
100
+ fragment("low", "Low priority", "review_model = \"cloud-low-review\""),
101
+ ],
102
+ &base_dir,
103
+ )
104
+ .expect("cloud config layers should compose"),
105
+ );
106
+ layers.push(ConfigLayerEntry::new(
107
+ ConfigLayerSource::User {
108
+ file: test_path_buf("/home/alice/.codex/config.toml").abs(),
109
+ profile: None,
110
+ },
111
+ toml("model = \"user\""),
112
+ ));
113
+
114
+ let stack = ConfigLayerStack::new(
115
+ layers,
116
+ ConfigRequirements::default(),
117
+ ConfigRequirementsToml::default(),
118
+ )
119
+ .expect("stack should be ordered");
120
+
121
+ assert_eq!(
122
+ stack
123
+ .layers_low_to_high()
124
+ .map(|layer| layer.name.clone())
125
+ .collect::<Vec<_>>(),
126
+ vec![
127
+ ConfigLayerSource::System {
128
+ file: test_path_buf("/etc/codex/config.toml").abs(),
129
+ },
130
+ ConfigLayerSource::EnterpriseManaged {
131
+ id: "low".to_string(),
132
+ name: "Low priority".to_string(),
133
+ },
134
+ ConfigLayerSource::EnterpriseManaged {
135
+ id: "high".to_string(),
136
+ name: "High priority".to_string(),
137
+ },
138
+ ConfigLayerSource::User {
139
+ file: test_path_buf("/home/alice/.codex/config.toml").abs(),
140
+ profile: None,
141
+ },
142
+ ]
143
+ );
144
+ assert_eq!(
145
+ stack.effective_config(),
146
+ toml(
147
+ r#"
148
+ model = "user"
149
+ model_provider = "cloud-high"
150
+ review_model = "cloud-low-review"
151
+ "#,
152
+ )
153
+ );
154
+ }
155
+
156
+ #[test]
157
+ fn relative_absolute_path_fields_resolve_against_base_dir() {
158
+ let base_dir = base_dir();
159
+ let layers = cloud_config_layers_from_fragments(
160
+ vec![fragment(
161
+ "cfg_123",
162
+ "Base policy",
163
+ "model_instructions_file = \"instructions.md\"",
164
+ )],
165
+ &base_dir,
166
+ )
167
+ .expect("relative paths should match existing MDM semantics");
168
+
169
+ let path = layers[0]
170
+ .config
171
+ .get("model_instructions_file")
172
+ .and_then(TomlValue::as_str)
173
+ .expect("path should be present");
174
+ let expected =
175
+ AbsolutePathBuf::resolve_path_against_base("instructions.md", base_dir.as_path());
176
+ assert_eq!(path, expected.to_string_lossy());
177
+ }
178
+
179
+ #[test]
180
+ fn home_relative_path_fields_are_allowed_and_resolved() {
181
+ let base_dir = base_dir();
182
+ let layers = cloud_config_layers_from_fragments(
183
+ vec![fragment(
184
+ "cfg_123",
185
+ "Base policy",
186
+ "model_instructions_file = \"~/instructions.md\"",
187
+ )],
188
+ &base_dir,
189
+ )
190
+ .expect("home-relative paths should be accepted");
191
+
192
+ let path = layers[0]
193
+ .config
194
+ .get("model_instructions_file")
195
+ .and_then(TomlValue::as_str)
196
+ .expect("path should be present");
197
+ let expected =
198
+ AbsolutePathBuf::resolve_path_against_base("~/instructions.md", base_dir.as_path());
199
+ assert_eq!(path, expected.to_string_lossy());
200
+ }
201
+
202
+ #[tokio::test]
203
+ async fn raw_toml_diagnostics_use_enterprise_layer_name() {
204
+ let base_dir = base_dir();
205
+ let layers = cloud_config_layers_from_fragments(
206
+ vec![fragment(
207
+ "cfg_123",
208
+ "Base policy",
209
+ "model_instructions_file = \"instructions.md\"\nmodel = 1",
210
+ )],
211
+ &base_dir,
212
+ )
213
+ .expect("cloud config layers should parse");
214
+
215
+ let error = first_layer_config_error_from_entries::<ConfigToml>(&layers, CONFIG_TOML_FILE)
216
+ .await
217
+ .expect("invalid raw TOML should produce a layer diagnostic");
218
+
219
+ assert_eq!(
220
+ error.path,
221
+ Path::new("enterprise-managed (Base policy, cfg_123)").to_path_buf()
222
+ );
223
+ assert_eq!(error.range.start.line, 2);
224
+ assert_eq!(error.range.start.column, 9);
225
+ assert!(error.message.contains("invalid type: integer `1`"));
226
+ }
codex-rs/config/src/config_layer_source.rs ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_utils_absolute_path::AbsolutePathBuf;
2
+ use serde_json::Value as JsonValue;
3
+
4
+ /// Provenance for one layer in the effective Codex configuration.
5
+ #[derive(Debug, Clone, PartialEq, Eq)]
6
+ pub enum ConfigLayerSource {
7
+ /// Default configuration supplied with the installed Codex package.
8
+ PackagedDefaults { file: AbsolutePathBuf },
9
+ /// Managed preferences delivered by MDM.
10
+ Mdm { domain: String, key: String },
11
+ /// Host-wide configuration loaded from a file.
12
+ System { file: AbsolutePathBuf },
13
+ /// Configuration delivered by an enterprise cloud bundle.
14
+ EnterpriseManaged { id: String, name: String },
15
+ /// User configuration, optionally augmented by a selected profile.
16
+ User {
17
+ file: AbsolutePathBuf,
18
+ profile: Option<String>,
19
+ },
20
+ /// Configuration loaded from a project's `.codex` directory.
21
+ Project { dot_codex_folder: AbsolutePathBuf },
22
+ /// Overrides supplied for the current session.
23
+ SessionFlags,
24
+ /// Legacy managed configuration loaded from a file.
25
+ LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf },
26
+ /// Legacy managed configuration delivered by MDM.
27
+ LegacyManagedConfigTomlFromMdm,
28
+ }
29
+
30
+ impl ConfigLayerSource {
31
+ /// A setting from a layer with a higher precedence overrides a setting
32
+ /// from a layer with a lower precedence.
33
+ pub fn precedence(&self) -> i16 {
34
+ match self {
35
+ ConfigLayerSource::PackagedDefaults { .. } => -10,
36
+ ConfigLayerSource::Mdm { .. } => 0,
37
+ ConfigLayerSource::System { .. } => 10,
38
+ ConfigLayerSource::EnterpriseManaged { .. } => 15,
39
+ ConfigLayerSource::User { profile, .. } => {
40
+ if profile.is_some() {
41
+ 21
42
+ } else {
43
+ 20
44
+ }
45
+ }
46
+ ConfigLayerSource::Project { .. } => 25,
47
+ ConfigLayerSource::SessionFlags => 30,
48
+ ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
49
+ ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
50
+ }
51
+ }
52
+ }
53
+
54
+ /// Compares [`ConfigLayerSource`] by precedence, so `A < B` means settings
55
+ /// from layer `A` will be overridden by settings from layer `B`.
56
+ impl PartialOrd for ConfigLayerSource {
57
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
58
+ Some(self.precedence().cmp(&other.precedence()))
59
+ }
60
+ }
61
+
62
+ /// Identity and version information for a configuration layer.
63
+ #[derive(Debug, Clone, PartialEq)]
64
+ pub struct ConfigLayerMetadata {
65
+ pub name: ConfigLayerSource,
66
+ pub version: String,
67
+ }
68
+
69
+ /// A materialized configuration layer and its provenance.
70
+ #[derive(Debug, Clone, PartialEq)]
71
+ pub struct ConfigLayer {
72
+ pub name: ConfigLayerSource,
73
+ pub version: String,
74
+ pub config: JsonValue,
75
+ pub disabled_reason: Option<String>,
76
+ }
77
+
78
+ pub fn format_config_layer_source(source: &ConfigLayerSource, config_toml_file: &str) -> String {
79
+ match source {
80
+ ConfigLayerSource::PackagedDefaults { file } => {
81
+ format!("packaged defaults ({})", file.as_path().display())
82
+ }
83
+ ConfigLayerSource::Mdm { domain, key } => {
84
+ format!("MDM ({domain}:{key})")
85
+ }
86
+ ConfigLayerSource::System { file } => {
87
+ format!("system ({})", file.as_path().display())
88
+ }
89
+ ConfigLayerSource::EnterpriseManaged { id, name } => {
90
+ format!("enterprise-managed ({name}, {id})")
91
+ }
92
+ ConfigLayerSource::User { file, .. } => {
93
+ format!("user ({})", file.as_path().display())
94
+ }
95
+ ConfigLayerSource::Project { dot_codex_folder } => {
96
+ format!(
97
+ "project ({}/{config_toml_file})",
98
+ dot_codex_folder.as_path().display()
99
+ )
100
+ }
101
+ ConfigLayerSource::SessionFlags => "session-flags".to_string(),
102
+ ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
103
+ format!("legacy managed_config.toml ({})", file.as_path().display())
104
+ }
105
+ ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
106
+ "legacy managed_config.toml (MDM)".to_string()
107
+ }
108
+ }
109
+ }
codex-rs/config/src/hooks_tests.rs ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use pretty_assertions::assert_eq;
2
+
3
+ use std::collections::BTreeMap;
4
+
5
+ use super::HookEventsToml;
6
+ use super::HookHandlerConfig;
7
+ use super::HooksFile;
8
+ use super::HooksToml;
9
+ use super::ManagedHooksRequirementsToml;
10
+ use super::MatcherGroup;
11
+
12
+ #[test]
13
+ fn hooks_file_deserializes_existing_json_shape() {
14
+ let parsed: HooksFile = serde_json::from_str(
15
+ r#"{
16
+ "description": "Optional stop-time review gate for Codex Companion.",
17
+ "hooks": {
18
+ "PreToolUse": [
19
+ {
20
+ "matcher": "^Bash$",
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "python3 /tmp/pre.py",
25
+ "timeout": 10,
26
+ "statusMessage": "checking",
27
+ "additionalContextLimit": 4096
28
+ }
29
+ ]
30
+ }
31
+ ]
32
+ }
33
+ }"#,
34
+ )
35
+ .expect("hooks.json should deserialize");
36
+
37
+ assert_eq!(
38
+ parsed,
39
+ HooksFile {
40
+ description: Some("Optional stop-time review gate for Codex Companion.".to_string()),
41
+ hooks: HookEventsToml {
42
+ pre_tool_use: vec![MatcherGroup {
43
+ matcher: Some("^Bash$".to_string()),
44
+ hooks: vec![HookHandlerConfig::Command {
45
+ command: "python3 /tmp/pre.py".to_string(),
46
+ command_windows: None,
47
+ timeout_sec: Some(10),
48
+ r#async: false,
49
+ status_message: Some("checking".to_string()),
50
+ additional_context_limit: Some(4096),
51
+ }],
52
+ }],
53
+ ..Default::default()
54
+ },
55
+ }
56
+ );
57
+ }
58
+
59
+ #[test]
60
+ fn hooks_file_deserializes_mcp_tool_handler_with_json_inputs() {
61
+ let parsed: HooksFile = serde_json::from_value(serde_json::json!({
62
+ "hooks": {
63
+ "PostToolUse": [{
64
+ "matcher": "Write|Edit",
65
+ "hooks": [{
66
+ "type": "mcp_tool",
67
+ "server": "security",
68
+ "tool": "scan",
69
+ "input": {
70
+ "file_path": "${tool_input.file_path}",
71
+ "include_ignored": false,
72
+ },
73
+ "timeout": 30,
74
+ "statusMessage": "Scanning file",
75
+ }],
76
+ }],
77
+ },
78
+ }))
79
+ .expect("MCP tool hooks should deserialize");
80
+
81
+ assert_eq!(
82
+ parsed.hooks.post_tool_use[0].hooks,
83
+ vec![HookHandlerConfig::McpTool {
84
+ server: "security".to_string(),
85
+ tool: "scan".to_string(),
86
+ input: serde_json::Map::from_iter([
87
+ (
88
+ "file_path".to_string(),
89
+ serde_json::Value::String("${tool_input.file_path}".to_string()),
90
+ ),
91
+ (
92
+ "include_ignored".to_string(),
93
+ serde_json::Value::Bool(false)
94
+ ),
95
+ ]),
96
+ timeout_sec: Some(30),
97
+ status_message: Some("Scanning file".to_string()),
98
+ }]
99
+ );
100
+ }
101
+
102
+ #[test]
103
+ fn hooks_file_rejects_mcp_tool_handler_with_null_input() {
104
+ for input in [
105
+ serde_json::json!({ "optional": null }),
106
+ serde_json::json!({ "metadata": { "optional": null } }),
107
+ serde_json::json!({ "values": [null] }),
108
+ ] {
109
+ let error = serde_json::from_value::<HooksFile>(serde_json::json!({
110
+ "hooks": {
111
+ "PostToolUse": [{
112
+ "hooks": [{
113
+ "type": "mcp_tool",
114
+ "server": "security",
115
+ "tool": "scan",
116
+ "input": input,
117
+ }],
118
+ }],
119
+ },
120
+ }))
121
+ .expect_err("literal null MCP hook arguments should be rejected");
122
+
123
+ assert!(
124
+ error
125
+ .to_string()
126
+ .contains("MCP hook input must be representable as TOML"),
127
+ "unexpected parse error: {error}"
128
+ );
129
+ }
130
+ }
131
+
132
+ #[test]
133
+ fn hooks_file_rejects_events_outside_hooks_object() {
134
+ let error = serde_json::from_str::<HooksFile>(
135
+ r#"{
136
+ "SessionStart": [
137
+ {
138
+ "hooks": [
139
+ {
140
+ "type": "command",
141
+ "command": "python3 /tmp/session_start.py"
142
+ }
143
+ ]
144
+ }
145
+ ]
146
+ }"#,
147
+ )
148
+ .expect_err("root-level hook events should be rejected");
149
+
150
+ assert!(
151
+ error.to_string().contains("unknown field `SessionStart`"),
152
+ "unexpected parse error: {error}"
153
+ );
154
+ }
155
+
156
+ #[test]
157
+ fn hook_events_deserialize_from_toml_arrays_of_tables() {
158
+ let parsed: HookEventsToml = toml::from_str(
159
+ r#"
160
+ [[PreToolUse]]
161
+ matcher = "^Bash$"
162
+
163
+ [[PreToolUse.hooks]]
164
+ type = "command"
165
+ command = "python3 /tmp/pre.py"
166
+ timeout = 10
167
+ statusMessage = "checking"
168
+ additionalContextLimit = 4096
169
+ "#,
170
+ )
171
+ .expect("hook events TOML should deserialize");
172
+
173
+ assert_eq!(
174
+ parsed,
175
+ HookEventsToml {
176
+ pre_tool_use: vec![MatcherGroup {
177
+ matcher: Some("^Bash$".to_string()),
178
+ hooks: vec![HookHandlerConfig::Command {
179
+ command: "python3 /tmp/pre.py".to_string(),
180
+ command_windows: None,
181
+ timeout_sec: Some(10),
182
+ r#async: false,
183
+ status_message: Some("checking".to_string()),
184
+ additional_context_limit: Some(4096),
185
+ }],
186
+ }],
187
+ ..Default::default()
188
+ }
189
+ );
190
+ }
191
+
192
+ #[test]
193
+ fn hooks_toml_deserializes_inline_events_and_state_map() {
194
+ let parsed: HooksToml = toml::from_str(
195
+ r#"
196
+ [state."/tmp/hooks.json:pre_tool_use:0:0"]
197
+ enabled = false
198
+ trusted_hash = "sha256:abc123"
199
+
200
+ [[PreToolUse]]
201
+ matcher = "^Bash$"
202
+
203
+ [[PreToolUse.hooks]]
204
+ type = "command"
205
+ command = "python3 /tmp/pre.py"
206
+ "#,
207
+ )
208
+ .expect("hooks TOML should deserialize");
209
+
210
+ assert_eq!(
211
+ parsed,
212
+ HooksToml {
213
+ events: HookEventsToml {
214
+ pre_tool_use: vec![MatcherGroup {
215
+ matcher: Some("^Bash$".to_string()),
216
+ hooks: vec![HookHandlerConfig::Command {
217
+ command: "python3 /tmp/pre.py".to_string(),
218
+ command_windows: None,
219
+ timeout_sec: None,
220
+ r#async: false,
221
+ status_message: None,
222
+ additional_context_limit: None,
223
+ }],
224
+ }],
225
+ ..Default::default()
226
+ },
227
+ state: BTreeMap::from([(
228
+ "/tmp/hooks.json:pre_tool_use:0:0".to_string(),
229
+ super::HookStateToml {
230
+ enabled: Some(false),
231
+ trusted_hash: Some("sha256:abc123".to_string()),
232
+ },
233
+ )]),
234
+ }
235
+ );
236
+ }
237
+
238
+ #[test]
239
+ fn managed_hooks_requirements_flatten_hook_events() {
240
+ let parsed: ManagedHooksRequirementsToml = toml::from_str(
241
+ r#"
242
+ managed_dir = "/enterprise/place"
243
+
244
+ [[PreToolUse]]
245
+ matcher = "^Bash$"
246
+
247
+ [[PreToolUse.hooks]]
248
+ type = "command"
249
+ command = "python3 /enterprise/place/pre.py"
250
+ "#,
251
+ )
252
+ .expect("requirements hooks TOML should deserialize");
253
+
254
+ assert_eq!(
255
+ parsed,
256
+ ManagedHooksRequirementsToml {
257
+ managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
258
+ windows_managed_dir: None,
259
+ hooks: HookEventsToml {
260
+ pre_tool_use: vec![MatcherGroup {
261
+ matcher: Some("^Bash$".to_string()),
262
+ hooks: vec![HookHandlerConfig::Command {
263
+ command: "python3 /enterprise/place/pre.py".to_string(),
264
+ command_windows: None,
265
+ timeout_sec: None,
266
+ r#async: false,
267
+ status_message: None,
268
+ additional_context_limit: None,
269
+ }],
270
+ }],
271
+ ..Default::default()
272
+ },
273
+ }
274
+ );
275
+ }
276
+
277
+ #[test]
278
+ fn hook_events_deserialize_windows_override_from_toml() {
279
+ let parsed: HookEventsToml = toml::from_str(
280
+ r#"
281
+ [[PreToolUse]]
282
+ matcher = "^Bash$"
283
+
284
+ [[PreToolUse.hooks]]
285
+ type = "command"
286
+ command = "bash /enterprise/hooks/pre.sh"
287
+ command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
288
+ "#,
289
+ )
290
+ .expect("hook command Windows override TOML should deserialize");
291
+
292
+ assert_eq!(
293
+ parsed,
294
+ HookEventsToml {
295
+ pre_tool_use: vec![MatcherGroup {
296
+ matcher: Some("^Bash$".to_string()),
297
+ hooks: vec![HookHandlerConfig::Command {
298
+ command: "bash /enterprise/hooks/pre.sh".to_string(),
299
+ command_windows: Some(
300
+ r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(),
301
+ ),
302
+ timeout_sec: None,
303
+ r#async: false,
304
+ status_message: None,
305
+ additional_context_limit: None,
306
+ }],
307
+ }],
308
+ ..Default::default()
309
+ }
310
+ );
311
+ }
312
+
313
+ #[test]
314
+ fn hook_events_deserialize_camel_case_windows_override_from_toml() {
315
+ let parsed: HookEventsToml = toml::from_str(
316
+ r#"
317
+ [[PreToolUse]]
318
+ matcher = "^Bash$"
319
+
320
+ [[PreToolUse.hooks]]
321
+ type = "command"
322
+ command = "bash /enterprise/hooks/pre.sh"
323
+ commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
324
+ "#,
325
+ )
326
+ .expect("camelCase hook command Windows override TOML should deserialize");
327
+
328
+ assert_eq!(
329
+ parsed,
330
+ HookEventsToml {
331
+ pre_tool_use: vec![MatcherGroup {
332
+ matcher: Some("^Bash$".to_string()),
333
+ hooks: vec![HookHandlerConfig::Command {
334
+ command: "bash /enterprise/hooks/pre.sh".to_string(),
335
+ command_windows: Some(
336
+ r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(),
337
+ ),
338
+ timeout_sec: None,
339
+ r#async: false,
340
+ status_message: None,
341
+ additional_context_limit: None,
342
+ }],
343
+ }],
344
+ ..Default::default()
345
+ }
346
+ );
347
+ }
348
+
349
+ #[test]
350
+ fn hook_handler_omits_unset_additional_context_limit() {
351
+ let handler = HookHandlerConfig::Command {
352
+ command: "python3 /tmp/pre.py".to_string(),
353
+ command_windows: None,
354
+ timeout_sec: None,
355
+ r#async: false,
356
+ status_message: None,
357
+ additional_context_limit: None,
358
+ };
359
+
360
+ let serialized = serde_json::to_value(handler).expect("hook handler should serialize");
361
+
362
+ assert_eq!(serialized.get("additionalContextLimit"), None);
363
+ }
codex-rs/config/src/host_name.rs ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #[cfg(unix)]
2
+ use dns_lookup::AddrInfoHints;
3
+ #[cfg(unix)]
4
+ use dns_lookup::getaddrinfo;
5
+ use std::sync::LazyLock;
6
+ #[cfg(windows)]
7
+ use winapi_util::sysinfo::ComputerNameKind;
8
+ #[cfg(windows)]
9
+ use winapi_util::sysinfo::get_computer_name;
10
+
11
+ static HOST_NAME: LazyLock<Option<String>> = LazyLock::new(compute_host_name);
12
+ static OS_HOST_NAME: LazyLock<Option<String>> = LazyLock::new(|| {
13
+ let kernel_hostname = gethostname::gethostname();
14
+ normalize_host_name(&kernel_hostname.to_string_lossy())
15
+ });
16
+
17
+ /// Returns a process-cached canonical hostname, falling back to the normalized
18
+ /// kernel hostname. The first call on Unix may perform blocking DNS resolution.
19
+ pub fn host_name() -> Option<String> {
20
+ HOST_NAME.clone()
21
+ }
22
+
23
+ /// Returns the process-cached operating-system hostname without DNS resolution.
24
+ pub fn os_host_name() -> Option<String> {
25
+ OS_HOST_NAME.clone()
26
+ }
27
+
28
+ fn compute_host_name() -> Option<String> {
29
+ let kernel_hostname = os_host_name()?;
30
+
31
+ // Remote sandbox requirements are meant to target remote hosts by DNS name,
32
+ // so prefer the canonical FQDN when the local resolver can provide one.
33
+ // This is best-effort host classification, not authenticated device proof.
34
+ if let Some(fqdn) = local_fqdn_for_hostname(&kernel_hostname) {
35
+ return Some(fqdn);
36
+ }
37
+
38
+ // Some machines have only a short local hostname or resolver setup that
39
+ // does not return AI_CANONNAME. Keep matching behavior best-effort by
40
+ // falling back to the cleaned kernel hostname instead of returning None.
41
+ Some(kernel_hostname)
42
+ }
43
+
44
+ fn normalize_host_name(hostname: &str) -> Option<String> {
45
+ let hostname = hostname.trim().trim_end_matches('.');
46
+ (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase())
47
+ }
48
+
49
+ #[cfg(unix)]
50
+ fn local_fqdn_for_hostname(hostname: &str) -> Option<String> {
51
+ let hints = AddrInfoHints {
52
+ flags: libc::AI_CANONNAME,
53
+ ..AddrInfoHints::default()
54
+ };
55
+
56
+ getaddrinfo(Some(hostname), /*service*/ None, Some(hints))
57
+ .ok()?
58
+ .filter_map(Result::ok)
59
+ .filter_map(|addr| addr.canonname)
60
+ // getaddrinfo may return the short hostname as canonname when no FQDN
61
+ // is available. Treat only DNS-qualified names as an FQDN result.
62
+ .find_map(|hostname| normalize_fqdn_candidate(&hostname))
63
+ }
64
+
65
+ #[cfg(windows)]
66
+ fn local_fqdn_for_hostname(_hostname: &str) -> Option<String> {
67
+ get_computer_name(ComputerNameKind::PhysicalDnsFullyQualified)
68
+ .ok()
69
+ .and_then(|hostname| hostname.into_string().ok())
70
+ .and_then(|hostname| normalize_fqdn_candidate(&hostname))
71
+ }
72
+
73
+ #[cfg(not(any(unix, windows)))]
74
+ fn local_fqdn_for_hostname(_hostname: &str) -> Option<String> {
75
+ None
76
+ }
77
+
78
+ fn normalize_fqdn_candidate(hostname: &str) -> Option<String> {
79
+ normalize_host_name(hostname).filter(|hostname| hostname.contains('.'))
80
+ }
81
+
82
+ #[cfg(test)]
83
+ mod tests {
84
+ use super::normalize_fqdn_candidate;
85
+ use super::normalize_host_name;
86
+ use super::os_host_name;
87
+ use pretty_assertions::assert_eq;
88
+
89
+ #[test]
90
+ fn os_host_name_matches_normalized_kernel_hostname() {
91
+ let kernel_hostname = gethostname::gethostname();
92
+
93
+ assert_eq!(
94
+ os_host_name(),
95
+ normalize_host_name(&kernel_hostname.to_string_lossy())
96
+ );
97
+ }
98
+
99
+ #[test]
100
+ fn normalize_fqdn_candidate_accepts_dns_qualified_name() {
101
+ assert_eq!(
102
+ normalize_fqdn_candidate("runner-01.ci.example.com"),
103
+ Some("runner-01.ci.example.com".to_string())
104
+ );
105
+ }
106
+
107
+ #[test]
108
+ fn normalize_fqdn_candidate_rejects_short_name() {
109
+ assert_eq!(normalize_fqdn_candidate("runner-01"), None);
110
+ }
111
+
112
+ #[test]
113
+ fn normalize_fqdn_candidate_trims_trailing_dot_and_normalizes_case() {
114
+ assert_eq!(
115
+ normalize_fqdn_candidate("RUNNER-01.CI.EXAMPLE.COM."),
116
+ Some("runner-01.ci.example.com".to_string())
117
+ );
118
+ }
119
+ }
codex-rs/config/src/in_app_browser_requirements_tests.rs ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::InAppBrowserRequirementsToml;
2
+ use crate::ConfigRequirementsToml;
3
+ use crate::ConfigRequirementsWithSources;
4
+ use crate::RequirementSource;
5
+ use crate::RequirementsLayerEntry;
6
+ use crate::Sourced;
7
+ use crate::compose_requirements_for_hostname;
8
+ use pretty_assertions::assert_eq;
9
+
10
+ const EMPTY: &str = "[in_app_browser]";
11
+ const ALLOW: &str = "[in_app_browser]\nallow_external_browser_settings_import = true";
12
+ const DENY: &str = "[in_app_browser]\nallow_external_browser_settings_import = false";
13
+
14
+ fn parse(contents: &str) -> ConfigRequirementsToml {
15
+ toml::from_str(contents).expect("parse requirements")
16
+ }
17
+
18
+ fn source(name: &str) -> RequirementSource {
19
+ RequirementSource::EnterpriseManaged {
20
+ id: name.to_string(),
21
+ name: name.to_string(),
22
+ }
23
+ }
24
+
25
+ #[test]
26
+ fn import_requirement_preserves_unset_and_explicit_values() {
27
+ for (contents, expected_table) in [
28
+ ("", None),
29
+ (EMPTY, Some(None)),
30
+ (ALLOW, Some(Some(true))),
31
+ (DENY, Some(Some(false))),
32
+ ] {
33
+ let requirements = parse(contents);
34
+ assert_eq!(
35
+ requirements,
36
+ ConfigRequirementsToml {
37
+ in_app_browser: expected_table.map(|value| InAppBrowserRequirementsToml {
38
+ allow_external_browser_settings_import: value,
39
+ }),
40
+ ..Default::default()
41
+ },
42
+ );
43
+ let configured = requirements
44
+ .in_app_browser
45
+ .as_ref()
46
+ .and_then(|policy| policy.allow_external_browser_settings_import);
47
+ assert_eq!(requirements.is_empty(), configured.is_none());
48
+ }
49
+ }
50
+
51
+ #[test]
52
+ fn import_requirement_uses_normal_managed_toml_precedence() {
53
+ for (low, high, expected) in [
54
+ ("", EMPTY, None),
55
+ (DENY, "", Some(DENY)),
56
+ (DENY, EMPTY, Some(DENY)),
57
+ (DENY, ALLOW, Some(ALLOW)),
58
+ (ALLOW, DENY, Some(DENY)),
59
+ ] {
60
+ let composed = compose_requirements_for_hostname(
61
+ [
62
+ RequirementsLayerEntry::from_toml(source("low"), low),
63
+ RequirementsLayerEntry::from_toml(source("high"), high),
64
+ ],
65
+ /*hostname*/ None,
66
+ )
67
+ .expect("compose managed import policy")
68
+ .map(ConfigRequirementsWithSources::into_toml);
69
+ assert_eq!(composed, expected.map(parse));
70
+ }
71
+
72
+ let composed = compose_requirements_for_hostname(
73
+ [RequirementsLayerEntry::from_toml(source("managed"), DENY)],
74
+ /*hostname*/ None,
75
+ )
76
+ .expect("compose managed denial");
77
+ assert_eq!(
78
+ composed,
79
+ Some(ConfigRequirementsWithSources {
80
+ in_app_browser: Some(Sourced::new(
81
+ InAppBrowserRequirementsToml {
82
+ allow_external_browser_settings_import: Some(false),
83
+ },
84
+ source("managed"),
85
+ )),
86
+ ..Default::default()
87
+ }),
88
+ );
89
+ }
90
+
91
+ #[test]
92
+ fn legacy_merge_uses_whole_table_precedence() {
93
+ for (high, low, expected, expected_source) in [
94
+ (EMPTY, DENY, EMPTY, "high"),
95
+ ("", ALLOW, ALLOW, "low"),
96
+ (DENY, ALLOW, DENY, "high"),
97
+ (ALLOW, DENY, ALLOW, "high"),
98
+ ] {
99
+ let mut composed = ConfigRequirementsWithSources::default();
100
+ composed.merge_unset_fields(source("high"), parse(high));
101
+ composed.merge_unset_fields(source("low"), parse(low));
102
+ assert_eq!(
103
+ composed,
104
+ ConfigRequirementsWithSources {
105
+ in_app_browser: Some(Sourced::new(
106
+ parse(expected).in_app_browser.expect("requirements table"),
107
+ source(expected_source),
108
+ )),
109
+ ..Default::default()
110
+ },
111
+ );
112
+ assert_eq!(composed.into_toml(), parse(expected));
113
+ }
114
+ }
115
+
116
+ #[test]
117
+ fn invalid_import_requirement_is_rejected_even_when_overridden() {
118
+ for invalid in ["\"false\"", "1", "[]", "{}"] {
119
+ let contents =
120
+ format!("[in_app_browser]\nallow_external_browser_settings_import = {invalid}");
121
+ let error = toml::from_str::<ConfigRequirementsToml>(&contents)
122
+ .expect_err("import requirement must be a boolean");
123
+ assert!(
124
+ error
125
+ .to_string()
126
+ .contains("allow_external_browser_settings_import"),
127
+ "{error}",
128
+ );
129
+ let error = compose_requirements_for_hostname(
130
+ [
131
+ RequirementsLayerEntry::from_toml(source("invalid"), contents),
132
+ RequirementsLayerEntry::from_toml(source("higher"), ALLOW),
133
+ ],
134
+ /*hostname*/ None,
135
+ )
136
+ .expect_err("invalid managed layers must not be silently ignored");
137
+ assert!(
138
+ error
139
+ .to_string()
140
+ .contains("allow_external_browser_settings_import"),
141
+ "{error}",
142
+ );
143
+ }
144
+ }
codex-rs/config/src/loader/README.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `codex-config` loader
2
+
3
+ This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, cloud-managed config, managed config, and MDM-managed preferences) and to produce:
4
+
5
+ - An **effective merged** TOML config.
6
+ - **Per-key origins** metadata (which layer “wins” for a given key).
7
+ - **Per-layer versions** (stable fingerprints) used for optimistic concurrency / conflict detection.
8
+
9
+ ## Public surface
10
+
11
+ Exported from `codex_config::loader`:
12
+
13
+ - `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, thread_config_loader) -> ConfigLayerStack`
14
+ - `ConfigLayerStack`
15
+ - `effective_config() -> toml::Value`
16
+ - `origins() -> HashMap<String, ConfigLayerMetadata>`
17
+ - `layers_high_to_low() -> impl Iterator<Item = &ConfigLayerEntry>`
18
+ - `with_user_config(user_config) -> ConfigLayerStack`
19
+ - `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata)
20
+ - `ConfigLoadOptions` (user-facing load behavior such as strict config validation)
21
+ - `LoaderOverrides` (test/override hooks for managed config sources)
22
+ - `merge_toml_values(base, overlay)` (public helper used elsewhere)
23
+
24
+ ## Layering model
25
+
26
+ Precedence is **top overrides bottom**:
27
+
28
+ 1. `LegacyManagedConfigTomlFromMdm` (MDM-delivered `managed_config.toml`, while it is being phased out)
29
+ 2. `LegacyManagedConfigTomlFromFile` (`managed_config.toml`, while it is being phased out)
30
+ 3. `SessionFlags` (CLI overrides, applied as dotted-path TOML writes)
31
+ 4. `Project` config (`.codex/config.toml`)
32
+ 5. `User` profile config, when present
33
+ 6. `User` config (`config.toml`)
34
+ 7. `EnterpriseManaged` cloud-managed config bundle layers
35
+ 8. `System` config (`/etc/codex/config.toml` or the Windows system config path)
36
+
37
+ `ConfigLayerStack` stores layers in the opposite order internally: lowest
38
+ precedence first, highest precedence last, so later layers override earlier
39
+ layers when folded. Thread config entries supplied by `thread_config_loader` are
40
+ inserted according to their translated `ConfigLayerSource` precedence.
41
+
42
+ Project-root discovery and project trust use the applicable non-project layers,
43
+ including legacy managed-file and MDM config at their normal precedence. The
44
+ managed layers remain above project and session layers in the final stack.
45
+ Executor-local reads use their own system, base-user, and legacy managed sources;
46
+ they do not include cloud config, selected profiles, or session flags.
47
+
48
+ Layers with a `disabled_reason` are still surfaced for UI, but are ignored when
49
+ computing the effective config and origins metadata. This is what
50
+ `ConfigLayerStack::effective_config()` implements.
51
+
52
+ ## Typical usage
53
+
54
+ Most callers want the effective config plus metadata:
55
+
56
+ ```rust
57
+ use codex_config::LoaderOverrides;
58
+ use codex_config::NoopThreadConfigLoader;
59
+ use codex_config::loader::load_config_layers_state;
60
+ use codex_exec_server::LOCAL_FS;
61
+ use codex_utils_absolute_path::AbsolutePathBuf;
62
+ use toml::Value as TomlValue;
63
+
64
+ let cli_overrides: Vec<(String, TomlValue)> = Vec::new();
65
+ let cwd = AbsolutePathBuf::current_dir()?;
66
+ let layers = load_config_layers_state(
67
+ LOCAL_FS.as_ref(),
68
+ &codex_home,
69
+ Some(cwd),
70
+ &cli_overrides,
71
+ LoaderOverrides::default(),
72
+ &NoopThreadConfigLoader,
73
+ ).await?;
74
+
75
+ let effective = layers.effective_config();
76
+ let origins = layers.origins();
77
+ let layers_for_ui = layers.layers_high_to_low().collect::<Vec<_>>();
78
+ ```
79
+
80
+ ## Internal layout
81
+
82
+ Implementation is split by concern:
83
+
84
+ - `state.rs`: public types (`ConfigLayerEntry`, `ConfigLayerStack`) + merge/origins convenience methods.
85
+ - `layer_io.rs`: reading `config.toml`, managed config, and managed preferences inputs.
86
+ - `project_discovery.rs`: shared managed-config composition for project-root and trust discovery.
87
+ - `overrides.rs`: CLI dotted-path overrides → TOML “session flags” layer.
88
+ - `merge.rs`: recursive TOML merge.
89
+ - `fingerprint.rs`: stable per-layer hashing and per-key origins traversal.
90
+ - `macos.rs`: managed preferences integration (macOS only).
codex-rs/config/src/loader/layer_io.rs ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #[cfg(target_os = "macos")]
2
+ use super::macos::ManagedAdminConfigLayer;
3
+ #[cfg(target_os = "macos")]
4
+ use super::macos::load_managed_admin_config_layer;
5
+ use crate::config_toml::ConfigToml;
6
+ use crate::diagnostics::config_error_from_toml;
7
+ use crate::diagnostics::io_error_from_config_error;
8
+ use crate::state::LoaderOverrides;
9
+ use crate::strict_config::config_error_from_ignored_toml_value_fields;
10
+ use codex_file_system::ExecutorFileSystem;
11
+ #[cfg(windows)]
12
+ use codex_file_system::GetMetadataOptions;
13
+ use codex_utils_absolute_path::AbsolutePathBuf;
14
+ use codex_utils_absolute_path::AbsolutePathBufGuard;
15
+ use codex_utils_path_uri::PathUri;
16
+ use std::io;
17
+ use std::path::Path;
18
+ use std::path::PathBuf;
19
+ use toml::Value as TomlValue;
20
+
21
+ #[cfg(unix)]
22
+ const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml";
23
+
24
+ #[derive(Debug, Clone)]
25
+ pub(super) struct MangedConfigFromFile {
26
+ pub managed_config: TomlValue,
27
+ pub file: AbsolutePathBuf,
28
+ }
29
+
30
+ #[derive(Debug, Clone)]
31
+ pub(super) struct ManagedConfigFromMdm {
32
+ pub managed_config: TomlValue,
33
+ pub raw_toml: String,
34
+ }
35
+
36
+ #[derive(Debug, Clone)]
37
+ pub(super) struct LoadedConfigLayers {
38
+ /// If present, data read from a file such as `/etc/codex/managed_config.toml`.
39
+ pub managed_config: Option<MangedConfigFromFile>,
40
+ /// If present, data read from managed preferences (macOS only).
41
+ pub managed_config_from_mdm: Option<ManagedConfigFromMdm>,
42
+ /// User-facing warnings discovered while loading managed configuration.
43
+ pub startup_warnings: Vec<String>,
44
+ }
45
+
46
+ pub(super) async fn load_config_layers_internal(
47
+ fs: &dyn ExecutorFileSystem,
48
+ codex_home: &Path,
49
+ overrides: LoaderOverrides,
50
+ strict_config: bool,
51
+ ) -> io::Result<LoadedConfigLayers> {
52
+ #[cfg(target_os = "macos")]
53
+ let LoaderOverrides {
54
+ managed_config_path,
55
+ managed_preferences_base64,
56
+ ..
57
+ } = overrides;
58
+
59
+ #[cfg(not(target_os = "macos"))]
60
+ let LoaderOverrides {
61
+ managed_config_path,
62
+ ..
63
+ } = overrides;
64
+
65
+ #[cfg(windows)]
66
+ let ignore_default_managed_config = managed_config_path.is_none();
67
+ #[cfg(not(windows))]
68
+ let ignore_default_managed_config = false;
69
+
70
+ let managed_config_path = AbsolutePathBuf::from_absolute_path(
71
+ managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)),
72
+ )?;
73
+
74
+ #[cfg(windows)]
75
+ let startup_warnings = if ignore_default_managed_config {
76
+ let path_uri = PathUri::from_abs_path(&managed_config_path);
77
+ match fs
78
+ .get_metadata(
79
+ &path_uri,
80
+ GetMetadataOptions::default(),
81
+ /*sandbox*/ None,
82
+ )
83
+ .await
84
+ {
85
+ Ok(_) => vec![format!(
86
+ "Ignoring deprecated managed config file at {}; CODEX_HOME/managed_config.toml is no longer supported on Windows. Use %ProgramData%\\OpenAI\\Codex\\requirements.toml for enforced settings or config.toml for defaults.",
87
+ managed_config_path.as_path().display()
88
+ )],
89
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Vec::new(),
90
+ Err(err) => {
91
+ tracing::debug!(
92
+ error = %err,
93
+ path = %managed_config_path.as_path().display(),
94
+ "Failed to check deprecated managed config file"
95
+ );
96
+ Vec::new()
97
+ }
98
+ }
99
+ } else {
100
+ Vec::new()
101
+ };
102
+ #[cfg(not(windows))]
103
+ let startup_warnings = Vec::new();
104
+
105
+ let managed_config = if ignore_default_managed_config {
106
+ None
107
+ } else {
108
+ read_config_from_path(
109
+ fs,
110
+ &managed_config_path,
111
+ /*log_missing_as_info*/ false,
112
+ strict_config,
113
+ )
114
+ .await?
115
+ .map(|loaded| MangedConfigFromFile {
116
+ managed_config: loaded,
117
+ file: managed_config_path.clone(),
118
+ })
119
+ };
120
+
121
+ #[cfg(target_os = "macos")]
122
+ let managed_preferences = load_managed_admin_config_layer(
123
+ managed_preferences_base64.as_deref(),
124
+ strict_config,
125
+ codex_home,
126
+ )
127
+ .await?
128
+ .map(map_managed_admin_layer);
129
+
130
+ #[cfg(not(target_os = "macos"))]
131
+ let managed_preferences = None;
132
+
133
+ Ok(LoadedConfigLayers {
134
+ managed_config,
135
+ managed_config_from_mdm: managed_preferences,
136
+ startup_warnings,
137
+ })
138
+ }
139
+
140
+ #[cfg(target_os = "macos")]
141
+ fn map_managed_admin_layer(layer: ManagedAdminConfigLayer) -> ManagedConfigFromMdm {
142
+ let ManagedAdminConfigLayer { config, raw_toml } = layer;
143
+ ManagedConfigFromMdm {
144
+ managed_config: config,
145
+ raw_toml,
146
+ }
147
+ }
148
+
149
+ pub(super) async fn read_config_from_path(
150
+ fs: &dyn ExecutorFileSystem,
151
+ path: &AbsolutePathBuf,
152
+ log_missing_as_info: bool,
153
+ strict_config: bool,
154
+ ) -> io::Result<Option<TomlValue>> {
155
+ let path_uri = PathUri::from_abs_path(path);
156
+ match fs
157
+ .read_file_text(&path_uri, Default::default(), /*sandbox*/ None)
158
+ .await
159
+ {
160
+ Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
161
+ Ok(value) => {
162
+ if strict_config {
163
+ validate_config_toml_strictly(path, &contents, &value)?;
164
+ }
165
+ Ok(Some(value))
166
+ }
167
+ Err(err) => {
168
+ tracing::error!("Failed to parse {}: {err}", path.as_path().display());
169
+ let config_error = config_error_from_toml(path.as_path(), &contents, err.clone());
170
+ Err(io_error_from_config_error(
171
+ io::ErrorKind::InvalidData,
172
+ config_error,
173
+ Some(err),
174
+ ))
175
+ }
176
+ },
177
+ Err(err) if err.kind() == io::ErrorKind::NotFound => {
178
+ if log_missing_as_info {
179
+ tracing::info!("{} not found, using defaults", path.as_path().display());
180
+ } else {
181
+ tracing::debug!("{} not found", path.as_path().display());
182
+ }
183
+ Ok(None)
184
+ }
185
+ Err(err) => {
186
+ tracing::error!("Failed to read {}: {err}", path.as_path().display());
187
+ Err(err)
188
+ }
189
+ }
190
+ }
191
+
192
+ fn validate_config_toml_strictly(
193
+ path: &AbsolutePathBuf,
194
+ contents: &str,
195
+ value: &TomlValue,
196
+ ) -> io::Result<()> {
197
+ let Some(base_dir) = path.as_path().parent() else {
198
+ return Err(io::Error::new(
199
+ io::ErrorKind::InvalidData,
200
+ format!("Config file {} has no parent directory", path.display()),
201
+ ));
202
+ };
203
+ let _guard = AbsolutePathBufGuard::new(base_dir);
204
+ if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
205
+ path.as_path(),
206
+ contents,
207
+ value.clone(),
208
+ ) {
209
+ return Err(io_error_from_config_error(
210
+ io::ErrorKind::InvalidData,
211
+ config_error,
212
+ /*source*/ None,
213
+ ));
214
+ }
215
+
216
+ Ok(())
217
+ }
218
+
219
+ /// Return the legacy managed config path.
220
+ ///
221
+ /// On Windows, the default path is only checked so callers can warn that it is ignored.
222
+ pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf {
223
+ #[cfg(unix)]
224
+ {
225
+ let _ = codex_home;
226
+ PathBuf::from(CODEX_MANAGED_CONFIG_SYSTEM_PATH)
227
+ }
228
+
229
+ #[cfg(not(unix))]
230
+ {
231
+ codex_home.join("managed_config.toml")
232
+ }
233
+ }
codex-rs/config/src/loader/local.rs ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::CredentialBrokerProjectState;
2
+ use super::apply_credential_broker_requirements;
3
+ use super::credential_broker_trusted_config;
4
+ use super::discover_project_layers;
5
+ use super::layer_io;
6
+ use super::load_config_toml_for_required_layer_raw;
7
+ use super::load_requirements_toml;
8
+ use super::load_root_checkout_project_config;
9
+ use super::project_discovery;
10
+ use super::project_root_markers_from_config;
11
+ use super::project_trust_context;
12
+ use super::requirements_layers_from_legacy_scheme;
13
+ use super::system_config_toml_file_with_overrides;
14
+ use super::system_requirements_toml_file_with_overrides;
15
+ use crate::CONFIG_TOML_FILE;
16
+ use crate::ConfigLayerSource;
17
+ use crate::LoaderOverrides;
18
+ use crate::RequirementSource;
19
+ use crate::RequirementsLayerEntry;
20
+ use crate::compose_requirements;
21
+ use crate::default_project_root_markers;
22
+ use crate::merge_toml_values;
23
+ use codex_file_system::ExecutorFileSystem;
24
+ use codex_utils_absolute_path::AbsolutePathBuf;
25
+ use std::collections::BTreeMap;
26
+ use std::io;
27
+ use std::path::Path;
28
+ use toml::Value as TomlValue;
29
+
30
+ /// Executor-local configuration and requirements layers before schema-specific
31
+ /// path resolution or requirements composition.
32
+ #[derive(Debug, Clone, PartialEq)]
33
+ pub struct LocalConfigLayers {
34
+ pub config: LocalTomlLayerStack<ConfigLayerSource>,
35
+ pub requirements: LocalTomlLayerStack<RequirementSource>,
36
+ }
37
+
38
+ impl LocalConfigLayers {
39
+ /// Retains only the requested TOML paths and drops empty layers.
40
+ ///
41
+ /// An empty path selects the entire document. RPC boundaries should reject
42
+ /// that form if whole-document reads are not part of their contract.
43
+ pub fn project(self, config_paths: &[Vec<String>], requirements_paths: &[Vec<String>]) -> Self {
44
+ Self {
45
+ config: self.config.project(config_paths),
46
+ requirements: self.requirements.project(requirements_paths),
47
+ }
48
+ }
49
+ }
50
+
51
+ /// One ordered set of executor-local TOML layers.
52
+ #[derive(Debug, Clone, PartialEq)]
53
+ pub struct LocalTomlLayerStack<S> {
54
+ /// Layers ordered from lowest to highest precedence.
55
+ pub layers: Vec<LocalTomlLayer<S>>,
56
+ /// Position at which a caller should insert cloud-provided layers.
57
+ pub cloud_insertion_index: usize,
58
+ }
59
+
60
+ impl<S> LocalTomlLayerStack<S> {
61
+ fn project(self, paths: &[Vec<String>]) -> Self {
62
+ let selectors = SelectorNode::from_paths(paths);
63
+ let mut projected_layers = Vec::new();
64
+ let mut cloud_insertion_index = 0;
65
+ for (index, layer) in self.layers.into_iter().enumerate() {
66
+ let Some(toml) = project_toml(&layer.toml, &selectors) else {
67
+ continue;
68
+ };
69
+ if index < self.cloud_insertion_index {
70
+ cloud_insertion_index += 1;
71
+ }
72
+ projected_layers.push(LocalTomlLayer { toml, ..layer });
73
+ }
74
+ Self {
75
+ layers: projected_layers,
76
+ cloud_insertion_index,
77
+ }
78
+ }
79
+ }
80
+
81
+ /// One executor-local TOML source with the directory used to interpret its
82
+ /// relative paths.
83
+ #[derive(Debug, Clone, PartialEq)]
84
+ pub struct LocalTomlLayer<S> {
85
+ pub source: S,
86
+ pub base_dir: AbsolutePathBuf,
87
+ pub toml: TomlValue,
88
+ }
89
+
90
+ /// Loads the fixed executor-local configuration sources used by environment
91
+ /// config reads.
92
+ ///
93
+ /// Cloud, selected profiles, session flags, and thread-provided layers are not
94
+ /// included. Project discovery uses the executor's system, base-user, and
95
+ /// legacy managed configuration.
96
+ pub async fn load_local_config_layers(
97
+ fs: &dyn ExecutorFileSystem,
98
+ codex_home: &Path,
99
+ cwd: &AbsolutePathBuf,
100
+ ) -> io::Result<LocalConfigLayers> {
101
+ load_local_config_layers_with_overrides(fs, codex_home, cwd, &LoaderOverrides::default()).await
102
+ }
103
+
104
+ pub(super) async fn load_local_config_layers_with_overrides(
105
+ fs: &dyn ExecutorFileSystem,
106
+ codex_home: &Path,
107
+ cwd: &AbsolutePathBuf,
108
+ overrides: &LoaderOverrides,
109
+ ) -> io::Result<LocalConfigLayers> {
110
+ let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?;
111
+ let loaded_managed = layer_io::load_config_layers_internal(
112
+ fs,
113
+ codex_home.as_path(),
114
+ overrides.clone(),
115
+ /*strict_config*/ false,
116
+ )
117
+ .await?;
118
+
119
+ let system_file = system_config_toml_file_with_overrides(overrides)?;
120
+ let system =
121
+ load_config_toml_for_required_layer_raw(fs, &system_file, /*strict_config*/ false).await?;
122
+ let user_file = codex_home.join(CONFIG_TOML_FILE);
123
+ let user =
124
+ load_config_toml_for_required_layer_raw(fs, &user_file, /*strict_config*/ false).await?;
125
+
126
+ let mut discovery_config = TomlValue::Table(toml::map::Map::new());
127
+ merge_toml_values(&mut discovery_config, &system.toml);
128
+ merge_toml_values(&mut discovery_config, &user.toml);
129
+ let trusted_broker_config =
130
+ credential_broker_trusted_config(&discovery_config, &[], &loaded_managed);
131
+ // Managed file and MDM values also govern the project boundary and trust.
132
+ // Only this snapshot is resolved; the returned local layers stay raw.
133
+ project_discovery::merge_managed_config_for_discovery(
134
+ &mut discovery_config,
135
+ &loaded_managed,
136
+ codex_home.as_path(),
137
+ )?;
138
+ let project_root_markers = project_root_markers_from_config(&discovery_config)?
139
+ .unwrap_or_else(default_project_root_markers);
140
+ let requirements =
141
+ local_requirements_layers(fs, codex_home.as_path(), overrides, loaded_managed.clone())
142
+ .await?;
143
+ let mut trust_context = project_trust_context(
144
+ fs,
145
+ &discovery_config,
146
+ &trusted_broker_config,
147
+ cwd,
148
+ &project_root_markers,
149
+ codex_home.as_path(),
150
+ &user_file,
151
+ )
152
+ .await?;
153
+ if trust_context.credential_broker != CredentialBrokerProjectState::Unconfigured {
154
+ let broker_requirements = requirements.clone().project(&[
155
+ vec!["features".to_string(), "network_proxy".to_string()],
156
+ vec![
157
+ "feature_requirements".to_string(),
158
+ "network_proxy".to_string(),
159
+ ],
160
+ vec!["experimental_network".to_string(), "enabled".to_string()],
161
+ ]);
162
+ let effective_requirements =
163
+ compose_requirements(broker_requirements.layers.into_iter().map(|layer| {
164
+ RequirementsLayerEntry::from_toml_value(layer.source, layer.toml)
165
+ .with_base_dir(layer.base_dir)
166
+ }))?
167
+ .unwrap_or_default();
168
+ apply_credential_broker_requirements(&mut trust_context, &effective_requirements);
169
+ }
170
+ let project_layers = discover_project_layers(
171
+ fs,
172
+ cwd,
173
+ &trust_context.project_root,
174
+ &trust_context,
175
+ codex_home.as_path(),
176
+ /*strict_config*/ false,
177
+ )
178
+ .await?;
179
+
180
+ let mut config_layers = vec![
181
+ LocalTomlLayer {
182
+ source: ConfigLayerSource::System { file: system_file },
183
+ base_dir: system.base_dir,
184
+ toml: system.toml,
185
+ },
186
+ LocalTomlLayer {
187
+ source: ConfigLayerSource::User {
188
+ file: user_file,
189
+ profile: None,
190
+ },
191
+ base_dir: user.base_dir,
192
+ toml: user.toml,
193
+ },
194
+ ];
195
+ append_project_layers(fs, &mut config_layers, project_layers.layers).await?;
196
+
197
+ append_legacy_config_layers(&mut config_layers, loaded_managed, &codex_home)?;
198
+
199
+ Ok(LocalConfigLayers {
200
+ config: LocalTomlLayerStack {
201
+ layers: config_layers,
202
+ // Cloud config follows the required system layer.
203
+ cloud_insertion_index: 1,
204
+ },
205
+ requirements,
206
+ })
207
+ }
208
+
209
+ async fn append_project_layers(
210
+ fs: &dyn ExecutorFileSystem,
211
+ output: &mut Vec<LocalTomlLayer<ConfigLayerSource>>,
212
+ layers: Vec<super::DiscoveredProjectLayer>,
213
+ ) -> io::Result<()> {
214
+ for layer in layers {
215
+ if layer.disabled_reason.is_some() {
216
+ continue;
217
+ }
218
+ let mut config = layer.config;
219
+ if layer.hooks_config_folder_override.is_some()
220
+ && let Some(table) = config.as_table_mut()
221
+ {
222
+ table.remove("hooks");
223
+ }
224
+ output.push(LocalTomlLayer {
225
+ source: ConfigLayerSource::Project {
226
+ dot_codex_folder: layer.dot_codex_folder.clone(),
227
+ },
228
+ base_dir: layer.dot_codex_folder,
229
+ toml: config,
230
+ });
231
+
232
+ let Some(hooks_config_folder) = layer.hooks_config_folder_override else {
233
+ continue;
234
+ };
235
+ let root_config =
236
+ load_root_checkout_project_config(fs, &hooks_config_folder, /*is_trusted*/ true)
237
+ .await?;
238
+ let Some(hooks) = root_config.get("hooks") else {
239
+ continue;
240
+ };
241
+ output.push(LocalTomlLayer {
242
+ source: ConfigLayerSource::Project {
243
+ dot_codex_folder: hooks_config_folder.clone(),
244
+ },
245
+ base_dir: hooks_config_folder,
246
+ toml: TomlValue::Table(toml::map::Map::from_iter([(
247
+ "hooks".to_string(),
248
+ hooks.clone(),
249
+ )])),
250
+ });
251
+ }
252
+ Ok(())
253
+ }
254
+
255
+ fn append_legacy_config_layers(
256
+ output: &mut Vec<LocalTomlLayer<ConfigLayerSource>>,
257
+ loaded: layer_io::LoadedConfigLayers,
258
+ codex_home: &AbsolutePathBuf,
259
+ ) -> io::Result<()> {
260
+ if let Some(config) = loaded.managed_config {
261
+ let base_dir = config.file.parent().ok_or_else(|| {
262
+ io::Error::new(
263
+ io::ErrorKind::InvalidData,
264
+ format!(
265
+ "Managed config file {} has no parent directory",
266
+ config.file.as_path().display()
267
+ ),
268
+ )
269
+ })?;
270
+ output.push(LocalTomlLayer {
271
+ source: ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file },
272
+ base_dir,
273
+ toml: config.managed_config,
274
+ });
275
+ }
276
+ if let Some(config) = loaded.managed_config_from_mdm {
277
+ output.push(LocalTomlLayer {
278
+ source: ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
279
+ base_dir: codex_home.clone(),
280
+ toml: config.managed_config,
281
+ });
282
+ }
283
+ Ok(())
284
+ }
285
+
286
+ async fn local_requirements_layers(
287
+ fs: &dyn ExecutorFileSystem,
288
+ codex_home: &Path,
289
+ overrides: &LoaderOverrides,
290
+ loaded_managed: layer_io::LoadedConfigLayers,
291
+ ) -> io::Result<LocalTomlLayerStack<RequirementSource>> {
292
+ let system_file = system_requirements_toml_file_with_overrides(overrides)?;
293
+ let system = load_requirements_toml(fs, &system_file).await?;
294
+ let cloud_insertion_index = usize::from(system.is_some());
295
+ let mut entries = Vec::new();
296
+ entries.extend(system);
297
+ entries.extend(requirements_layers_from_legacy_scheme(
298
+ loaded_managed,
299
+ codex_home,
300
+ )?);
301
+
302
+ #[cfg(target_os = "macos")]
303
+ {
304
+ let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?;
305
+ entries.extend(
306
+ super::macos::load_managed_admin_requirements_layer(
307
+ overrides
308
+ .macos_managed_config_requirements_base64
309
+ .as_deref(),
310
+ )
311
+ .await?
312
+ .map(|layer| layer.with_base_dir(codex_home)),
313
+ );
314
+ }
315
+
316
+ let mut layers = Vec::with_capacity(entries.len());
317
+ for entry in entries {
318
+ layers.push(local_requirements_layer(entry)?);
319
+ }
320
+ Ok(LocalTomlLayerStack {
321
+ layers,
322
+ cloud_insertion_index,
323
+ })
324
+ }
325
+
326
+ fn local_requirements_layer(
327
+ entry: RequirementsLayerEntry,
328
+ ) -> io::Result<LocalTomlLayer<RequirementSource>> {
329
+ let (source, toml, base_dir) = entry.into_raw_parts()?;
330
+ let base_dir = base_dir.ok_or_else(|| {
331
+ io::Error::new(
332
+ io::ErrorKind::InvalidData,
333
+ format!("requirements layer {source} has no base directory"),
334
+ )
335
+ })?;
336
+ Ok(LocalTomlLayer {
337
+ source,
338
+ base_dir,
339
+ toml,
340
+ })
341
+ }
342
+
343
+ #[derive(Default)]
344
+ struct SelectorNode {
345
+ terminal: bool,
346
+ children: BTreeMap<String, SelectorNode>,
347
+ }
348
+
349
+ impl SelectorNode {
350
+ fn from_paths(paths: &[Vec<String>]) -> Self {
351
+ let mut root = Self::default();
352
+ for path in paths {
353
+ root.insert(path);
354
+ }
355
+ root
356
+ }
357
+
358
+ fn insert(&mut self, path: &[String]) {
359
+ if self.terminal {
360
+ return;
361
+ }
362
+ let Some((segment, remaining)) = path.split_first() else {
363
+ self.terminal = true;
364
+ self.children.clear();
365
+ return;
366
+ };
367
+ self.children
368
+ .entry(segment.clone())
369
+ .or_default()
370
+ .insert(remaining);
371
+ }
372
+ }
373
+
374
+ fn project_toml(value: &TomlValue, selector: &SelectorNode) -> Option<TomlValue> {
375
+ let projected = project_toml_value(value, selector);
376
+ if !selector.terminal && projected.as_table().is_some_and(toml::map::Map::is_empty) {
377
+ return None;
378
+ }
379
+ Some(projected)
380
+ }
381
+
382
+ fn project_toml_value(value: &TomlValue, selector: &SelectorNode) -> TomlValue {
383
+ if selector.terminal {
384
+ return value.clone();
385
+ }
386
+ let Some(table) = value.as_table() else {
387
+ // Preserve a non-table ancestor so it can still override lower layers.
388
+ return value.clone();
389
+ };
390
+ let mut projected = toml::map::Map::new();
391
+ for (key, value) in table {
392
+ let Some(child_selector) = selector.children.get(key) else {
393
+ continue;
394
+ };
395
+ projected.insert(key.clone(), project_toml_value(value, child_selector));
396
+ }
397
+ TomlValue::Table(projected)
398
+ }
codex-rs/config/src/loader/macos.rs ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::RequirementsLayerEntry;
2
+ use crate::config_requirements::RequirementSource;
3
+ use crate::config_toml::ConfigToml;
4
+ use crate::diagnostics::ConfigDiagnosticSource;
5
+ use crate::diagnostics::config_error_from_toml_for_source;
6
+ use crate::diagnostics::io_error_from_config_error;
7
+ use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name;
8
+ use base64::Engine;
9
+ use base64::prelude::BASE64_STANDARD;
10
+ use codex_utils_absolute_path::AbsolutePathBufGuard;
11
+ use core_foundation::base::TCFType;
12
+ use core_foundation::string::CFString;
13
+ use core_foundation::string::CFStringRef;
14
+ use std::ffi::c_void;
15
+ use std::io;
16
+ use std::path::Path;
17
+ use tokio::task;
18
+ use toml::Value as TomlValue;
19
+
20
+ const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
21
+ const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
22
+ const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64";
23
+
24
+ #[derive(Debug, Clone)]
25
+ pub(super) struct ManagedAdminConfigLayer {
26
+ pub config: TomlValue,
27
+ pub raw_toml: String,
28
+ }
29
+
30
+ pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
31
+ RequirementSource::MdmManagedPreferences {
32
+ domain: MANAGED_PREFERENCES_APPLICATION_ID.to_string(),
33
+ key: MANAGED_PREFERENCES_REQUIREMENTS_KEY.to_string(),
34
+ }
35
+ }
36
+
37
+ pub(super) fn has_managed_preferences() -> io::Result<bool> {
38
+ Ok(
39
+ load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?.is_some()
40
+ || load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?.is_some(),
41
+ )
42
+ }
43
+
44
+ pub(crate) async fn load_managed_admin_config_layer(
45
+ override_base64: Option<&str>,
46
+ strict_config: bool,
47
+ base_dir: &Path,
48
+ ) -> io::Result<Option<ManagedAdminConfigLayer>> {
49
+ if let Some(encoded) = override_base64 {
50
+ let trimmed = encoded.trim();
51
+ return if trimmed.is_empty() {
52
+ Ok(None)
53
+ } else {
54
+ parse_managed_config_base64(trimmed, strict_config, base_dir).map(Some)
55
+ };
56
+ }
57
+
58
+ let base_dir = base_dir.to_path_buf();
59
+ match task::spawn_blocking(move || load_managed_admin_config(strict_config, &base_dir)).await {
60
+ Ok(result) => result,
61
+ Err(join_err) => {
62
+ if join_err.is_cancelled() {
63
+ tracing::error!("Managed config load task was cancelled");
64
+ } else {
65
+ tracing::error!("Managed config load task failed: {join_err}");
66
+ }
67
+ Err(io::Error::other("Failed to load managed config"))
68
+ }
69
+ }
70
+ }
71
+
72
+ fn load_managed_admin_config(
73
+ strict_config: bool,
74
+ base_dir: &Path,
75
+ ) -> io::Result<Option<ManagedAdminConfigLayer>> {
76
+ load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?
77
+ .as_deref()
78
+ .map(str::trim)
79
+ .map(|encoded| parse_managed_config_base64(encoded, strict_config, base_dir))
80
+ .transpose()
81
+ }
82
+
83
+ pub(crate) async fn load_managed_admin_requirements_layer(
84
+ override_base64: Option<&str>,
85
+ ) -> io::Result<Option<RequirementsLayerEntry>> {
86
+ if let Some(encoded) = override_base64 {
87
+ let trimmed = encoded.trim();
88
+ if trimmed.is_empty() {
89
+ return Ok(None);
90
+ }
91
+
92
+ return parse_managed_requirements_base64(trimmed).map(|contents| {
93
+ Some(RequirementsLayerEntry::from_toml(
94
+ managed_preferences_requirements_source(),
95
+ contents,
96
+ ))
97
+ });
98
+ }
99
+
100
+ match task::spawn_blocking(load_managed_admin_requirements).await {
101
+ Ok(result) => Ok(result?.map(|contents| {
102
+ RequirementsLayerEntry::from_toml(managed_preferences_requirements_source(), contents)
103
+ })),
104
+ Err(join_err) => {
105
+ if join_err.is_cancelled() {
106
+ tracing::error!("Managed requirements load task was cancelled");
107
+ } else {
108
+ tracing::error!("Managed requirements load task failed: {join_err}");
109
+ }
110
+ Err(io::Error::other("Failed to load managed requirements"))
111
+ }
112
+ }
113
+ }
114
+
115
+ fn load_managed_admin_requirements() -> io::Result<Option<String>> {
116
+ load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?
117
+ .as_deref()
118
+ .map(str::trim)
119
+ .map(parse_managed_requirements_base64)
120
+ .transpose()
121
+ }
122
+
123
+ fn load_managed_preference(key_name: &str) -> io::Result<Option<String>> {
124
+ #[link(name = "CoreFoundation", kind = "framework")]
125
+ unsafe extern "C" {
126
+ fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void;
127
+ }
128
+
129
+ let value_ref = unsafe {
130
+ CFPreferencesCopyAppValue(
131
+ CFString::new(key_name).as_concrete_TypeRef(),
132
+ CFString::new(MANAGED_PREFERENCES_APPLICATION_ID).as_concrete_TypeRef(),
133
+ )
134
+ };
135
+
136
+ if value_ref.is_null() {
137
+ tracing::debug!(
138
+ "Managed preferences for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name} not found",
139
+ );
140
+ return Ok(None);
141
+ }
142
+
143
+ let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }.to_string();
144
+ Ok(Some(value))
145
+ }
146
+
147
+ fn parse_managed_config_base64(
148
+ encoded: &str,
149
+ strict_config: bool,
150
+ base_dir: &Path,
151
+ ) -> io::Result<ManagedAdminConfigLayer> {
152
+ let raw_toml = decode_managed_preferences_base64(encoded)?;
153
+ let source_name =
154
+ format!("{MANAGED_PREFERENCES_APPLICATION_ID}:{MANAGED_PREFERENCES_CONFIG_KEY}");
155
+ let parsed = toml::from_str::<TomlValue>(&raw_toml).map_err(|err| {
156
+ tracing::error!("Failed to parse managed config TOML: {err}");
157
+ if strict_config {
158
+ let config_error = config_error_from_toml_for_source(
159
+ ConfigDiagnosticSource::DisplayName(&source_name),
160
+ &raw_toml,
161
+ err.clone(),
162
+ );
163
+ io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
164
+ } else {
165
+ io::Error::new(io::ErrorKind::InvalidData, err)
166
+ }
167
+ })?;
168
+
169
+ validate_managed_config_toml_strictly_if_requested(
170
+ strict_config,
171
+ &source_name,
172
+ &raw_toml,
173
+ &parsed,
174
+ base_dir,
175
+ )?;
176
+ match parsed {
177
+ TomlValue::Table(parsed) => Ok(ManagedAdminConfigLayer {
178
+ config: TomlValue::Table(parsed),
179
+ raw_toml,
180
+ }),
181
+ other => {
182
+ tracing::error!("Managed config TOML must have a table at the root, found {other:?}",);
183
+ Err(io::Error::new(
184
+ io::ErrorKind::InvalidData,
185
+ "managed config root must be a table",
186
+ ))
187
+ }
188
+ }
189
+ }
190
+
191
+ fn validate_managed_config_toml_strictly_if_requested(
192
+ strict_config: bool,
193
+ source_name: &str,
194
+ raw_toml: &str,
195
+ parsed: &TomlValue,
196
+ base_dir: &Path,
197
+ ) -> io::Result<()> {
198
+ if !strict_config {
199
+ return Ok(());
200
+ }
201
+
202
+ let _guard = AbsolutePathBufGuard::new(base_dir);
203
+ if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::<
204
+ ConfigToml,
205
+ >(source_name, raw_toml, parsed.clone())
206
+ {
207
+ Err(io_error_from_config_error(
208
+ io::ErrorKind::InvalidData,
209
+ config_error,
210
+ /*source*/ None,
211
+ ))
212
+ } else {
213
+ Ok(())
214
+ }
215
+ }
216
+
217
+ fn parse_managed_requirements_base64(encoded: &str) -> io::Result<String> {
218
+ decode_managed_preferences_base64(encoded)
219
+ }
220
+
221
+ fn decode_managed_preferences_base64(encoded: &str) -> io::Result<String> {
222
+ String::from_utf8(BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
223
+ tracing::error!("Failed to decode managed value as base64: {err}",);
224
+ io::Error::new(io::ErrorKind::InvalidData, err)
225
+ })?)
226
+ .map_err(|err| {
227
+ tracing::error!("Managed value base64 contents were not valid UTF-8: {err}",);
228
+ io::Error::new(io::ErrorKind::InvalidData, err)
229
+ })
230
+ }
codex-rs/config/src/loader/managed_project_discovery_tests.rs ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::ConfigLayerSource;
2
+ use crate::ConfigLayerStack;
3
+ use crate::LoaderOverrides;
4
+ use crate::NoopThreadConfigLoader;
5
+ use crate::loader::LocalConfigLayers;
6
+ use crate::loader::load_config_layers_state;
7
+ use crate::loader::local::load_local_config_layers_with_overrides;
8
+ use crate::loader::project_trust_key;
9
+ use crate::loader::tests::TestFileSystem;
10
+ use crate::merge_toml_values;
11
+ use codex_utils_absolute_path::AbsolutePathBuf;
12
+ use pretty_assertions::assert_eq;
13
+ use std::path::PathBuf;
14
+ use tempfile::TempDir;
15
+ use toml::Value as TomlValue;
16
+
17
+ struct Fixture {
18
+ _temp: TempDir,
19
+ home: PathBuf,
20
+ repo: AbsolutePathBuf,
21
+ project: AbsolutePathBuf,
22
+ cwd: AbsolutePathBuf,
23
+ managed: PathBuf,
24
+ overrides: LoaderOverrides,
25
+ }
26
+
27
+ impl Fixture {
28
+ fn new() -> anyhow::Result<Self> {
29
+ let temp = tempfile::tempdir()?;
30
+ let home = temp.path().join("home");
31
+ let repo = AbsolutePathBuf::from_absolute_path(temp.path().join("repo"))?;
32
+ let project = repo.join("project");
33
+ let cwd = project.join("child");
34
+ std::fs::create_dir_all(&home)?;
35
+ std::fs::create_dir_all(repo.join(".git"))?;
36
+ std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n")?;
37
+ for (dir, model) in [(&repo, "ancestor"), (&project, "project"), (&cwd, "child")] {
38
+ std::fs::create_dir_all(dir.join(".codex"))?;
39
+ std::fs::write(
40
+ dir.join(".codex/config.toml"),
41
+ format!("model = \"{model}\"\n"),
42
+ )?;
43
+ }
44
+ std::fs::write(project.join(".company-root"), "")?;
45
+ let managed = temp.path().join("managed_config.toml");
46
+ let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed.clone());
47
+ overrides.system_config_path = Some(temp.path().join("system.toml"));
48
+ let fixture = Self {
49
+ _temp: temp,
50
+ home,
51
+ repo,
52
+ project,
53
+ cwd,
54
+ managed,
55
+ overrides,
56
+ };
57
+ std::fs::write(fixture.home.join("config.toml"), fixture.trust("trusted"))?;
58
+ Ok(fixture)
59
+ }
60
+
61
+ fn trust(&self, level: &str) -> String {
62
+ let key = TomlValue::String(project_trust_key(self.repo.as_path()));
63
+ format!("[projects.{key}]\ntrust_level = \"{level}\"\n")
64
+ }
65
+
66
+ async fn load(&self) -> anyhow::Result<(ConfigLayerStack, LocalConfigLayers)> {
67
+ let canonical = load_config_layers_state(
68
+ &TestFileSystem,
69
+ &self.home,
70
+ Some(self.cwd.clone()),
71
+ &[
72
+ (
73
+ "project_root_markers".into(),
74
+ toml::Value::try_from([".git"])?,
75
+ ),
76
+ ("model".into(), TomlValue::String("session".into())),
77
+ ],
78
+ self.overrides.clone(),
79
+ &NoopThreadConfigLoader,
80
+ )
81
+ .await?;
82
+ let local = load_local_config_layers_with_overrides(
83
+ &TestFileSystem,
84
+ &self.home,
85
+ &self.cwd,
86
+ &self.overrides,
87
+ )
88
+ .await?;
89
+ Ok((canonical, local))
90
+ }
91
+ }
92
+
93
+ fn assert_discovery(
94
+ canonical: &ConfigLayerStack,
95
+ local: &LocalConfigLayers,
96
+ expected_dirs: &[&AbsolutePathBuf],
97
+ markers: &[&str],
98
+ ) -> anyhow::Result<()> {
99
+ let project_dirs = |sources: Vec<&ConfigLayerSource>| {
100
+ sources
101
+ .into_iter()
102
+ .filter_map(|source| match source {
103
+ ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
104
+ _ => None,
105
+ })
106
+ .collect::<Vec<_>>()
107
+ };
108
+ let expected_dirs = expected_dirs
109
+ .iter()
110
+ .map(|dir| dir.join(".codex"))
111
+ .collect::<Vec<_>>();
112
+ assert_eq!(
113
+ (
114
+ project_dirs(
115
+ canonical
116
+ .layers_low_to_high()
117
+ .map(|layer| &layer.name)
118
+ .collect()
119
+ ),
120
+ project_dirs(
121
+ local
122
+ .config
123
+ .layers
124
+ .iter()
125
+ .map(|layer| &layer.source)
126
+ .collect()
127
+ ),
128
+ ),
129
+ (expected_dirs.clone(), expected_dirs)
130
+ );
131
+ let effective = canonical.effective_config();
132
+ let mut local_effective = TomlValue::Table(toml::map::Map::new());
133
+ for layer in &local.config.layers {
134
+ merge_toml_values(&mut local_effective, &layer.toml);
135
+ }
136
+ let expected_markers = TomlValue::try_from(markers)?;
137
+ assert_eq!(
138
+ (
139
+ effective.get("project_root_markers"),
140
+ local_effective.get("project_root_markers"),
141
+ ),
142
+ (Some(&expected_markers), Some(&expected_markers))
143
+ );
144
+ Ok(())
145
+ }
146
+
147
+ #[tokio::test]
148
+ async fn managed_project_discovery_uses_file_markers_without_changing_precedence()
149
+ -> anyhow::Result<()> {
150
+ let fixture = Fixture::new()?;
151
+ for (markers, dirs) in [
152
+ (
153
+ vec![".git"],
154
+ vec![&fixture.repo, &fixture.project, &fixture.cwd],
155
+ ),
156
+ (vec![], vec![&fixture.cwd]),
157
+ (vec![".company-root"], vec![&fixture.project, &fixture.cwd]),
158
+ ] {
159
+ let markers_toml = TomlValue::try_from(&markers)?;
160
+ std::fs::write(
161
+ &fixture.managed,
162
+ format!("project_root_markers = {markers_toml}\nmodel = \"managed\"\n"),
163
+ )?;
164
+ let (canonical, local) = fixture.load().await?;
165
+ assert_discovery(&canonical, &local, &dirs, &markers)?;
166
+ let managed_source = ConfigLayerSource::LegacyManagedConfigTomlFromFile {
167
+ file: AbsolutePathBuf::from_absolute_path(&fixture.managed)?,
168
+ };
169
+ assert_eq!(
170
+ (
171
+ canonical.effective_config().get("model").cloned(),
172
+ &canonical.origins()["model"].name,
173
+ &local.config.layers.last().expect("managed layer").source,
174
+ ),
175
+ (
176
+ Some(TomlValue::String("managed".into())),
177
+ &managed_source,
178
+ &managed_source,
179
+ )
180
+ );
181
+ }
182
+ Ok(())
183
+ }
184
+
185
+ #[tokio::test]
186
+ async fn managed_project_discovery_ignores_incomplete_nested_git_directory() -> anyhow::Result<()> {
187
+ let fixture = Fixture::new()?;
188
+ std::fs::create_dir(fixture.cwd.join(".git"))?;
189
+ std::fs::write(&fixture.managed, "project_root_markers = [\".git\"]\n")?;
190
+
191
+ let (canonical, local) = fixture.load().await?;
192
+ assert_discovery(
193
+ &canonical,
194
+ &local,
195
+ &[&fixture.repo, &fixture.project, &fixture.cwd],
196
+ &[".git"],
197
+ )?;
198
+
199
+ std::fs::write(fixture.home.join("config.toml"), fixture.trust("untrusted"))?;
200
+ let (canonical, local) = fixture.load().await?;
201
+ assert_discovery(&canonical, &local, &[], &[".git"])?;
202
+ let trust_key = project_trust_key(fixture.repo.as_path());
203
+ assert!(
204
+ canonical
205
+ .all_layers_low_to_high()
206
+ .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
207
+ .all(|layer| layer.disabled_reason.as_deref().is_some_and(
208
+ |reason| reason.starts_with(&format!("{trust_key} is marked as untrusted"))
209
+ ))
210
+ );
211
+ Ok(())
212
+ }
213
+
214
+ #[tokio::test]
215
+ async fn managed_project_discovery_uses_managed_project_trust() -> anyhow::Result<()> {
216
+ let fixture = Fixture::new()?;
217
+ let trust_key = project_trust_key(fixture.repo.as_path());
218
+ let user_config = fixture.home.join("config.toml");
219
+ let untrusted_reason = format!(
220
+ "{trust_key} is marked as untrusted in the effective configuration. To load project-local config, hooks, and exec policies, update its trust setting. If that setting is managed by your organization, contact your administrator."
221
+ );
222
+ let unknown_reason = format!(
223
+ "To load project-local config, hooks, and exec policies, add {trust_key} as a trusted project in {}.",
224
+ user_config.display()
225
+ );
226
+ for (user, managed, dirs, expected_reason) in [
227
+ (
228
+ fixture.trust("trusted"),
229
+ fixture.trust("untrusted"),
230
+ vec![],
231
+ Some(untrusted_reason.as_str()),
232
+ ),
233
+ (
234
+ fixture.trust("untrusted"),
235
+ fixture.trust("trusted"),
236
+ vec![&fixture.cwd],
237
+ None,
238
+ ),
239
+ (
240
+ String::new(),
241
+ String::new(),
242
+ vec![],
243
+ Some(unknown_reason.as_str()),
244
+ ),
245
+ ] {
246
+ std::fs::write(&user_config, user)?;
247
+ std::fs::write(
248
+ &fixture.managed,
249
+ format!("project_root_markers = []\n{managed}"),
250
+ )?;
251
+ let (canonical, local) = fixture.load().await?;
252
+ assert_discovery(&canonical, &local, &dirs, &[])?;
253
+ assert_eq!(
254
+ canonical
255
+ .all_layers_low_to_high()
256
+ .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
257
+ .map(|layer| layer.disabled_reason.as_deref())
258
+ .collect::<Vec<_>>(),
259
+ vec![expected_reason]
260
+ );
261
+ }
262
+ Ok(())
263
+ }
264
+
265
+ #[cfg(target_os = "macos")]
266
+ #[tokio::test]
267
+ async fn managed_project_discovery_preserves_remapped_provider_bindings() -> anyhow::Result<()> {
268
+ use base64::Engine;
269
+ use base64::prelude::BASE64_STANDARD;
270
+
271
+ let mut fixture = Fixture::new()?;
272
+ let providers = "[features.network_proxy.credentials]\n\
273
+ a = { env = ['A_AUTH'], patterns = ['^pin_[a-z]{8}$'], url_prefixes = ['https://a.example'] }\n\
274
+ b = { env = ['B_AUTH'], patterns = ['^pin_[a-z]{8}$'], url_prefix_from_env = 'B_ENDPOINT' }\n";
275
+ std::fs::write(
276
+ fixture.home.join("config.toml"),
277
+ format!(
278
+ "{}\n[features.network_proxy]\nenabled = true\ncredential_broker = true\n\
279
+ {providers}\n[shell_environment_policy.set]\nB_ENDPOINT = 'https://b.example'\n",
280
+ fixture.trust("trusted")
281
+ ),
282
+ )?;
283
+ std::fs::write(
284
+ &fixture.managed,
285
+ "[features.network_proxy.credentials.a]\nenv = ['A_AUTH']\n",
286
+ )?;
287
+ fixture.overrides.managed_preferences_base64 = Some(BASE64_STANDARD.encode(
288
+ "[features.network_proxy.credentials]\na = { env = ['B_AUTH'] }\nb = { env = ['A_AUTH'] }\n",
289
+ ));
290
+ std::fs::write(
291
+ fixture.cwd.join(".codex/config.toml"),
292
+ "[shell_environment_policy.set]\nB_ENDPOINT = 'https://attacker.example'\nTOOL_MODE = 'project'\n",
293
+ )?;
294
+
295
+ let (canonical, local) = fixture.load().await?;
296
+ let mut local_effective = TomlValue::Table(toml::map::Map::new());
297
+ for layer in local.config.layers {
298
+ merge_toml_values(&mut local_effective, &layer.toml);
299
+ }
300
+ let mut expected: TomlValue = toml::from_str(providers)?;
301
+ let credentials = &mut expected["features"]["network_proxy"]["credentials"];
302
+ credentials["a"]["env"] = TomlValue::try_from(["B_AUTH"])?;
303
+ credentials["b"]["env"] = TomlValue::try_from(["A_AUTH"])?;
304
+ let expected_policy: TomlValue =
305
+ toml::from_str("[set]\nB_ENDPOINT = 'https://b.example'\nTOOL_MODE = 'project'\n")?;
306
+ for effective in [canonical.effective_config(), local_effective] {
307
+ assert_eq!(
308
+ &effective["features"]["network_proxy"]["credentials"],
309
+ &*credentials
310
+ );
311
+ assert_eq!(effective["shell_environment_policy"], expected_policy);
312
+ }
313
+ Ok(())
314
+ }
315
+
316
+ #[cfg(target_os = "macos")]
317
+ #[tokio::test]
318
+ async fn managed_project_discovery_mdm_overrides_file_markers() -> anyhow::Result<()> {
319
+ use base64::Engine;
320
+ use base64::prelude::BASE64_STANDARD;
321
+
322
+ let mut fixture = Fixture::new()?;
323
+ std::fs::write(&fixture.managed, "project_root_markers = [\".git\"]\n")?;
324
+ for (markers, dirs) in [
325
+ (vec![], vec![&fixture.cwd]),
326
+ (vec![".company-root"], vec![&fixture.project, &fixture.cwd]),
327
+ ] {
328
+ let markers_toml = TomlValue::try_from(&markers)?;
329
+ fixture.overrides.managed_preferences_base64 =
330
+ Some(BASE64_STANDARD.encode(format!("project_root_markers = {markers_toml}\n")));
331
+ let (canonical, local) = fixture.load().await?;
332
+ assert_discovery(&canonical, &local, &dirs, &markers)?;
333
+ assert_eq!(
334
+ (
335
+ canonical
336
+ .layers_high_to_low()
337
+ .next()
338
+ .map(|layer| &layer.name),
339
+ local.config.layers.last().map(|layer| &layer.source),
340
+ ),
341
+ (
342
+ Some(&ConfigLayerSource::LegacyManagedConfigTomlFromMdm),
343
+ Some(&ConfigLayerSource::LegacyManagedConfigTomlFromMdm),
344
+ )
345
+ );
346
+ }
347
+ Ok(())
348
+ }
codex-rs/config/src/loader/managed_requirements.rs ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Loads managed requirements independently of user and project configuration,
2
+ //! retaining source layers for diagnostics during full configuration loads.
3
+
4
+ use super::layer_io;
5
+ use super::layer_io::LoadedConfigLayers;
6
+ use super::load_requirements_toml;
7
+ #[cfg(target_os = "macos")]
8
+ use super::macos;
9
+ use super::requirements_layers_from_legacy_scheme;
10
+ use super::system_requirements_toml_file_with_overrides;
11
+ use crate::ConfigRequirements;
12
+ use crate::ConfigRequirementsToml;
13
+ use crate::RequirementsLayerEntry;
14
+ use crate::compose_requirements;
15
+ use crate::config_requirements::ConfigRequirementsWithSources;
16
+ use crate::state::ConfigLoadOptions;
17
+ use crate::state::LoaderOverrides;
18
+ use codex_file_system::ExecutorFileSystem;
19
+ use codex_utils_absolute_path::AbsolutePathBuf;
20
+ use std::io;
21
+ use std::path::Path;
22
+
23
+ /// Loads current managed requirements without reading system defaults, user or
24
+ /// project settings, or thread-provided configuration. Legacy managed config
25
+ /// remains a requirements source.
26
+ pub async fn load_managed_requirements_state(
27
+ fs: &dyn ExecutorFileSystem,
28
+ codex_home: &Path,
29
+ options: impl Into<ConfigLoadOptions>,
30
+ ) -> io::Result<ConfigRequirementsToml> {
31
+ let ConfigLoadOptions {
32
+ loader_overrides: overrides,
33
+ strict_config,
34
+ cloud_config_bundle,
35
+ } = options.into();
36
+ if overrides.ignore_managed_requirements {
37
+ return Ok(ConfigRequirementsToml::default());
38
+ }
39
+
40
+ let mut bundle_requirements = Vec::new();
41
+ if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? {
42
+ let base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
43
+ bundle_requirements = bundle.requirements_toml.into_layers(&base_dir);
44
+ }
45
+ let (requirements, _, _) = load_requirements_from_sources(
46
+ fs,
47
+ codex_home,
48
+ &overrides,
49
+ strict_config,
50
+ bundle_requirements,
51
+ )
52
+ .await?;
53
+ let _: ConfigRequirements = requirements.clone().try_into()?;
54
+ let requirements_toml = requirements.into_toml();
55
+ // Full config loads also validate required provider definitions when
56
+ // constructing ConfigLayerStack, including providers not in use by this thread.
57
+ let _ = crate::model_provider_requirements::to_config(&requirements_toml)?;
58
+ Ok(requirements_toml)
59
+ }
60
+
61
+ /// Composes the same requirements for full config loads and policy-only checks.
62
+ /// The loaded legacy config and source layers are also returned for the full
63
+ /// config stack and its ignored-field diagnostics.
64
+ pub(super) async fn load_requirements_from_sources(
65
+ fs: &dyn ExecutorFileSystem,
66
+ codex_home: &Path,
67
+ overrides: &LoaderOverrides,
68
+ strict_config: bool,
69
+ bundle_requirements: Vec<RequirementsLayerEntry>,
70
+ ) -> io::Result<(
71
+ ConfigRequirementsWithSources,
72
+ LoadedConfigLayers,
73
+ Vec<RequirementsLayerEntry>,
74
+ )> {
75
+ let mut requirements_layers = Vec::new();
76
+ let mut system_requirements_layer = None;
77
+ let managed_preferences_requirements_layer;
78
+ if !overrides.ignore_managed_requirements {
79
+ #[cfg(target_os = "macos")]
80
+ {
81
+ let base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
82
+ managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer(
83
+ overrides
84
+ .macos_managed_config_requirements_base64
85
+ .as_deref(),
86
+ )
87
+ .await?
88
+ .map(|layer| layer.with_base_dir(base_dir));
89
+ }
90
+ #[cfg(not(target_os = "macos"))]
91
+ {
92
+ managed_preferences_requirements_layer = None;
93
+ }
94
+
95
+ let requirements_file = system_requirements_toml_file_with_overrides(overrides)?;
96
+ system_requirements_layer = load_requirements_toml(fs, &requirements_file).await?;
97
+ } else {
98
+ managed_preferences_requirements_layer = None;
99
+ }
100
+
101
+ let loaded_config_layers =
102
+ layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config)
103
+ .await?;
104
+ if !overrides.ignore_managed_requirements {
105
+ requirements_layers.extend(system_requirements_layer);
106
+ requirements_layers.extend(bundle_requirements);
107
+ // Legacy managed_config.toml contributes approval and sandbox requirements.
108
+ requirements_layers.extend(requirements_layers_from_legacy_scheme(
109
+ loaded_config_layers.clone(),
110
+ codex_home,
111
+ )?);
112
+ requirements_layers.extend(managed_preferences_requirements_layer);
113
+ }
114
+
115
+ let mut requirements = compose_requirements(requirements_layers.clone())?.unwrap_or_default();
116
+ if overrides.ignore_login_requirements {
117
+ requirements.allowed_login_methods = None;
118
+ requirements.allowed_chatgpt_workspaces = None;
119
+ }
120
+ Ok((requirements, loaded_config_layers, requirements_layers))
121
+ }
codex-rs/config/src/loader/mod.rs ADDED
@@ -0,0 +1,1982 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod layer_io;
2
+ mod local;
3
+ #[cfg(target_os = "macos")]
4
+ mod macos;
5
+ mod managed_requirements;
6
+ mod project_discovery;
7
+ #[cfg(test)]
8
+ mod tests;
9
+ #[cfg(windows)]
10
+ mod windows;
11
+
12
+ use self::layer_io::LoadedConfigLayers;
13
+ use crate::CONFIG_TOML_FILE;
14
+ use crate::CloudConfigBundleLayers;
15
+ use crate::ConfigLayerSource;
16
+ use crate::ProfileV2Name;
17
+ use crate::RequirementsLayerEntry;
18
+ use crate::config_requirements::RequirementSource;
19
+ use crate::config_requirements::SandboxModeRequirement;
20
+ use crate::config_toml::ConfigToml;
21
+ use crate::config_toml::ProjectConfig;
22
+ use crate::diagnostics::ConfigError;
23
+ use crate::diagnostics::config_error_from_toml;
24
+ use crate::diagnostics::first_layer_config_error_from_entries as typed_first_layer_config_error_from_entries;
25
+ use crate::diagnostics::io_error_from_config_error;
26
+ use crate::merge::merge_toml_values;
27
+ use crate::overrides::build_cli_overrides_layer;
28
+ use crate::project_root_markers::default_project_root_markers;
29
+ use crate::project_root_markers::project_root_markers_from_config;
30
+ use crate::shell_environment_policy::ShellEnvironmentPolicyFilterConfigToml;
31
+ use crate::state::ConfigLayerEntry;
32
+ use crate::state::ConfigLayerStack;
33
+ use crate::state::ConfigLoadOptions;
34
+ use crate::state::LoaderOverrides;
35
+ use crate::state::validate_enabled_config_layers;
36
+ use crate::strict_config::config_error_from_ignored_toml_value_fields;
37
+ use crate::strict_config::ignored_config_warning;
38
+ use crate::strict_config::ignored_toml_value_fields;
39
+ use crate::strict_config::unknown_feature_toml_value_field;
40
+ use crate::thread_config::ThreadConfigContext;
41
+ use crate::thread_config::ThreadConfigLoader;
42
+ use codex_file_system::ExecutorFileSystem;
43
+ use codex_git_utils::resolve_root_git_project_for_trust;
44
+ use codex_network_proxy::is_credential_broker_provider_env_key;
45
+ use codex_protocol::config_types::ApprovalsReviewer;
46
+ use codex_protocol::config_types::SandboxMode;
47
+ use codex_protocol::config_types::TrustLevel;
48
+ use codex_protocol::protocol::AskForApproval;
49
+ use codex_utils_absolute_path::AbsolutePathBuf;
50
+ use codex_utils_absolute_path::AbsolutePathBufGuard;
51
+ use codex_utils_path_uri::PathUri;
52
+ use dunce::canonicalize as normalize_path;
53
+ use serde::Deserialize;
54
+ use std::io;
55
+ use std::path::Path;
56
+ #[cfg(windows)]
57
+ use std::path::PathBuf;
58
+ use toml::Value as TomlValue;
59
+
60
+ pub use local::LocalConfigLayers;
61
+ pub use local::LocalTomlLayer;
62
+ pub use local::LocalTomlLayerStack;
63
+ pub use local::load_local_config_layers;
64
+ pub use managed_requirements::load_managed_requirements_state;
65
+ #[cfg(windows)]
66
+ pub use windows::WindowsSystemConfigNamespaceProbe;
67
+ #[cfg(windows)]
68
+ pub use windows::probe_windows_system_config_namespace;
69
+
70
+ #[cfg(unix)]
71
+ const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
72
+
73
+ #[cfg(windows)]
74
+ const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
75
+
76
+ // Project-local config comes from repository contents, so it should not get to
77
+ // choose where a user's credentials are sent or which local commands are run.
78
+ // These settings are still supported from user, system, managed, and runtime
79
+ // config layers.
80
+ const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[
81
+ "openai_base_url",
82
+ "chatgpt_base_url",
83
+ "apps_mcp_product_sku",
84
+ "responses_api_metadata",
85
+ "model_provider",
86
+ "model_providers",
87
+ "notify",
88
+ "profile",
89
+ "profiles",
90
+ "experimental_realtime_webrtc_call_base_url",
91
+ "experimental_realtime_ws_base_url",
92
+ "otel",
93
+ ];
94
+
95
+ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option<ConfigError> {
96
+ typed_first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE).await
97
+ }
98
+
99
+ /// To build up the set of admin-enforced constraints, requirements layers are
100
+ /// collected in ascending precedence order, matching config layers, and then
101
+ /// composed with config-style TOML merging plus field-specific handling for
102
+ /// hooks, rules, deny-read permissions, and remote sandbox config:
103
+ ///
104
+ /// - system `/etc/codex/requirements.toml` (Unix) or
105
+ /// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows)
106
+ /// - cloud: enterprise-managed cloud config bundle requirements
107
+ /// - legacy: `/etc/codex/managed_config.toml` (Unix) reinterpreted as
108
+ /// requirements.toml
109
+ /// - admin: managed preferences (*)
110
+ ///
111
+ /// For backwards compatibility, Unix continues to load
112
+ /// `/etc/codex/managed_config.toml` and map it to `requirements.toml`.
113
+ ///
114
+ /// Configuration is built up from multiple layers in the following order:
115
+ ///
116
+ /// - package: optional default configuration supplied with the Codex package
117
+ /// - admin: managed preferences (*)
118
+ /// - system `/etc/codex/config.toml` (Unix) or
119
+ /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows)
120
+ /// - cloud enterprise-managed cloud config bundle fragments
121
+ /// - user `${CODEX_HOME}/config.toml`
122
+ /// - profile `${CODEX_HOME}/<name>.config.toml`, when selected
123
+ /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted)
124
+ /// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted)
125
+ /// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted)
126
+ /// - runtime e.g., --config flags, model selector in UI
127
+ ///
128
+ /// (*) Only available on macOS via managed device profiles.
129
+ ///
130
+ /// See https://developers.openai.com/codex/security for details.
131
+ ///
132
+ /// When loading the config stack for a thread, there should be a `cwd`
133
+ /// associated with it such that `cwd` should be `Some(...)`. Only for
134
+ /// thread-agnostic config loading (e.g., for the app server's `/config`
135
+ /// endpoint) should `cwd` be `None`.
136
+ #[allow(clippy::too_many_arguments)]
137
+ pub async fn load_config_layers_state(
138
+ fs: &dyn ExecutorFileSystem,
139
+ codex_home: &Path,
140
+ cwd: Option<AbsolutePathBuf>,
141
+ cli_overrides: &[(String, TomlValue)],
142
+ options: impl Into<ConfigLoadOptions>,
143
+ thread_config_loader: &dyn ThreadConfigLoader,
144
+ ) -> io::Result<ConfigLayerStack> {
145
+ let ConfigLoadOptions {
146
+ loader_overrides: overrides,
147
+ strict_config,
148
+ cloud_config_bundle,
149
+ } = options.into();
150
+ let packaged_defaults_layer = if let Some(file) = &overrides.packaged_defaults_path {
151
+ let config = layer_io::read_config_from_path(
152
+ fs,
153
+ file,
154
+ /*log_missing_as_info*/ false,
155
+ strict_config,
156
+ )
157
+ .await?
158
+ .ok_or_else(|| {
159
+ io::Error::new(
160
+ io::ErrorKind::NotFound,
161
+ format!("packaged defaults config file {} not found", file.display()),
162
+ )
163
+ })?;
164
+ let base_dir = file.as_path().parent().ok_or_else(|| {
165
+ io::Error::new(
166
+ io::ErrorKind::InvalidData,
167
+ format!(
168
+ "packaged defaults config file {} has no parent directory",
169
+ file.display()
170
+ ),
171
+ )
172
+ })?;
173
+ ConfigLayerEntry::new(
174
+ ConfigLayerSource::PackagedDefaults { file: file.clone() },
175
+ resolve_relative_paths_in_config_toml(config, base_dir)?,
176
+ )
177
+ } else {
178
+ let file = AbsolutePathBuf::from_absolute_path(std::env::current_exe()?)?;
179
+ let raw_toml = include_str!("../../defaults.toml");
180
+ let config = toml::from_str(raw_toml).map_err(|error| {
181
+ io::Error::new(
182
+ io::ErrorKind::InvalidData,
183
+ format!("invalid embedded packaged defaults; this is a Codex build error: {error}"),
184
+ )
185
+ })?;
186
+ ConfigLayerEntry::new_with_raw_toml(
187
+ ConfigLayerSource::PackagedDefaults { file },
188
+ config,
189
+ raw_toml.to_owned(),
190
+ AbsolutePathBuf::from_absolute_path(codex_home)?,
191
+ )
192
+ };
193
+ let active_user_profile = overrides.user_config_profile.clone();
194
+ let ignore_user_config = overrides.ignore_user_config;
195
+ let ignore_project_config = overrides.ignore_project_config;
196
+ let ignore_user_and_project_exec_policy_rules =
197
+ overrides.ignore_user_and_project_exec_policy_rules;
198
+ let mut bundle_requirements_layers = Vec::new();
199
+ let mut cloud_config_layers = Vec::new();
200
+
201
+ if !overrides.ignore_managed_requirements
202
+ && let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)?
203
+ {
204
+ let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
205
+ let bundle_layers = if strict_config {
206
+ CloudConfigBundleLayers::from_bundle_strict_config(bundle, &cloud_config_base_dir)?
207
+ } else {
208
+ CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)?
209
+ };
210
+ let CloudConfigBundleLayers {
211
+ enterprise_managed_config,
212
+ enterprise_managed_requirements,
213
+ } = bundle_layers;
214
+ bundle_requirements_layers = enterprise_managed_requirements;
215
+ cloud_config_layers = enterprise_managed_config;
216
+ }
217
+
218
+ let (config_requirements_toml, loaded_config_layers, requirements_layers) =
219
+ managed_requirements::load_requirements_from_sources(
220
+ fs,
221
+ codex_home,
222
+ &overrides,
223
+ strict_config,
224
+ bundle_requirements_layers,
225
+ )
226
+ .await?;
227
+ let mut startup_warnings = loaded_config_layers.startup_warnings.clone();
228
+
229
+ let thread_config_context = ThreadConfigContext {
230
+ thread_id: None,
231
+ cwd: cwd.clone(),
232
+ };
233
+ let thread_config_layers = thread_config_loader
234
+ .load_config_layers(thread_config_context)
235
+ .await
236
+ .map_err(io::Error::other)?;
237
+
238
+ let mut layers = Vec::<ConfigLayerEntry>::new();
239
+ layers.push(packaged_defaults_layer);
240
+
241
+ let cli_overrides_layer = if cli_overrides.is_empty() {
242
+ None
243
+ } else {
244
+ let cli_overrides_layer = build_cli_overrides_layer(cli_overrides);
245
+ let base_dir = cwd
246
+ .as_ref()
247
+ .map(AbsolutePathBuf::as_path)
248
+ .unwrap_or(codex_home);
249
+ if strict_config {
250
+ validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?;
251
+ }
252
+ Some(resolve_relative_paths_in_config_toml(
253
+ cli_overrides_layer,
254
+ base_dir,
255
+ )?)
256
+ };
257
+
258
+ // Include an entry for the "system" config folder, loading its config.toml,
259
+ // if it exists.
260
+ let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?;
261
+ let system_layer = load_config_toml_for_required_layer(
262
+ fs,
263
+ &system_config_toml_file,
264
+ strict_config,
265
+ |config_toml| {
266
+ ConfigLayerEntry::new(
267
+ ConfigLayerSource::System {
268
+ file: system_config_toml_file.clone(),
269
+ },
270
+ config_toml,
271
+ )
272
+ },
273
+ )
274
+ .await?;
275
+ layers.push(system_layer);
276
+ layers.extend(cloud_config_layers);
277
+
278
+ // Add the base user config layer. When profile-v2 is selected, add the
279
+ // profile config as a second user layer on top so the profile only needs to
280
+ // contain overrides.
281
+ let active_user_file = overrides.user_config_path(codex_home)?;
282
+ let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
283
+ let base_user_layer = load_user_config_layer(
284
+ fs,
285
+ &base_user_file,
286
+ /*profile*/ None,
287
+ ignore_user_config,
288
+ strict_config,
289
+ )
290
+ .await?;
291
+ if let Some(active_user_profile) = active_user_profile.as_ref()
292
+ && let Some(base_user_config) = base_user_layer.config.as_table()
293
+ {
294
+ let legacy_profile_is_selected = base_user_config
295
+ .get("profile")
296
+ .and_then(TomlValue::as_str)
297
+ .is_some_and(|profile| profile == active_user_profile.as_str());
298
+ let legacy_profile_table_exists = base_user_config
299
+ .get("profiles")
300
+ .and_then(TomlValue::as_table)
301
+ .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str()));
302
+ if legacy_profile_is_selected || legacy_profile_table_exists {
303
+ return Err(io::Error::new(
304
+ io::ErrorKind::InvalidData,
305
+ format!(
306
+ "--profile `{active_user_profile}` cannot be used while {} contains legacy `profile = \"{active_user_profile}\"` or `[profiles.{active_user_profile}]` config; move those settings into {} and remove the legacy profile selector/table. See https://developers.openai.com/codex/config-advanced#profiles for more information.",
307
+ base_user_file.as_path().display(),
308
+ active_user_file.as_path().display()
309
+ ),
310
+ ));
311
+ }
312
+ }
313
+ layers.push(base_user_layer);
314
+
315
+ if active_user_file != base_user_file {
316
+ layers.push(
317
+ load_user_config_layer(
318
+ fs,
319
+ &active_user_file,
320
+ active_user_profile.as_ref(),
321
+ ignore_user_config,
322
+ strict_config,
323
+ )
324
+ .await?,
325
+ );
326
+ }
327
+
328
+ if !ignore_project_config && let Some(cwd) = cwd {
329
+ let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
330
+ for layer in &layers {
331
+ merge_toml_values(&mut merged_so_far, &layer.config);
332
+ }
333
+ if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() {
334
+ merge_toml_values(&mut merged_so_far, cli_overrides_layer);
335
+ }
336
+ let trusted_broker_config = credential_broker_trusted_config(
337
+ &merged_so_far,
338
+ &thread_config_layers,
339
+ &loaded_config_layers,
340
+ );
341
+ // Managed config wins over CLI config. Apply it before choosing the
342
+ // project root and trust, but keep its final layers above project config.
343
+ project_discovery::merge_managed_config_for_discovery(
344
+ &mut merged_so_far,
345
+ &loaded_config_layers,
346
+ codex_home,
347
+ )?;
348
+
349
+ let project_root_markers = match project_root_markers_from_config(&merged_so_far) {
350
+ Ok(markers) => markers.unwrap_or_else(default_project_root_markers),
351
+ Err(err) => {
352
+ if let Some(config_error) = first_layer_config_error_from_entries(&layers).await {
353
+ return Err(io_error_from_config_error(
354
+ io::ErrorKind::InvalidData,
355
+ config_error,
356
+ /*source*/ None,
357
+ ));
358
+ }
359
+ return Err(err);
360
+ }
361
+ };
362
+ let mut project_trust_context = match project_trust_context(
363
+ fs,
364
+ &merged_so_far,
365
+ &trusted_broker_config,
366
+ &cwd,
367
+ &project_root_markers,
368
+ codex_home,
369
+ &active_user_file,
370
+ )
371
+ .await
372
+ {
373
+ Ok(context) => context,
374
+ Err(err) => {
375
+ let source = err
376
+ .get_ref()
377
+ .and_then(|err| err.downcast_ref::<toml::de::Error>())
378
+ .cloned();
379
+ if let Some(config_error) = first_layer_config_error_from_entries(&layers).await {
380
+ return Err(io_error_from_config_error(
381
+ io::ErrorKind::InvalidData,
382
+ config_error,
383
+ source,
384
+ ));
385
+ }
386
+ return Err(err);
387
+ }
388
+ };
389
+ apply_credential_broker_requirements(&mut project_trust_context, &config_requirements_toml);
390
+ let project_layers = load_project_layers(
391
+ fs,
392
+ &cwd,
393
+ &project_trust_context.project_root,
394
+ &project_trust_context,
395
+ codex_home,
396
+ strict_config,
397
+ )
398
+ .await?;
399
+ layers.extend(project_layers.layers);
400
+ startup_warnings.extend(project_layers.startup_warnings);
401
+ }
402
+
403
+ // Add a layer for runtime overrides from the CLI or UI, if any exist.
404
+ if let Some(cli_overrides_layer) = cli_overrides_layer {
405
+ layers.push(ConfigLayerEntry::new(
406
+ ConfigLayerSource::SessionFlags,
407
+ cli_overrides_layer,
408
+ ));
409
+ }
410
+
411
+ for thread_config_layer in thread_config_layers {
412
+ insert_layer_by_precedence(&mut layers, thread_config_layer);
413
+ }
414
+
415
+ // Make a best-effort to support the legacy `managed_config.toml` as a
416
+ // config layer on top of everything else. For fields in
417
+ // `managed_config.toml` that do not have an equivalent in
418
+ // `ConfigRequirements`, note users can still override these values on a
419
+ // per-turn basis in the TUI and VS Code.
420
+ let LoadedConfigLayers {
421
+ managed_config,
422
+ managed_config_from_mdm,
423
+ ..
424
+ } = loaded_config_layers;
425
+ if let Some(config) = managed_config {
426
+ let managed_parent = config.file.as_path().parent().ok_or_else(|| {
427
+ io::Error::new(
428
+ io::ErrorKind::InvalidData,
429
+ format!(
430
+ "Managed config file {} has no parent directory",
431
+ config.file.as_path().display()
432
+ ),
433
+ )
434
+ })?;
435
+ let managed_config =
436
+ resolve_relative_paths_in_config_toml(config.managed_config, managed_parent)?;
437
+ layers.push(ConfigLayerEntry::new(
438
+ ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file },
439
+ managed_config,
440
+ ));
441
+ }
442
+ if let Some(config) = managed_config_from_mdm {
443
+ // As a general rule, config from MDM should _not_ include relative
444
+ // paths, starting with `./`, but a path starting with `~/` _is_ a
445
+ // supported use case. Because resolve_relative_paths_in_config_toml()
446
+ // relies on AbsolutePathBufGuard to resolve `~/`, we must supply a
447
+ // value for base_dir. Preserve that same base on the layer so later
448
+ // raw-TOML diagnostics parse with the same path semantics.
449
+ let raw_toml_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
450
+ let managed_config = resolve_relative_paths_in_config_toml(
451
+ config.managed_config,
452
+ raw_toml_base_dir.as_path(),
453
+ )?;
454
+ layers.push(ConfigLayerEntry::new_with_raw_toml(
455
+ ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
456
+ managed_config,
457
+ config.raw_toml,
458
+ raw_toml_base_dir,
459
+ ));
460
+ }
461
+
462
+ if let Err(err) = validate_enabled_config_layers(&layers) {
463
+ if let Some(config_error) = typed_first_layer_config_error_from_entries::<
464
+ ShellEnvironmentPolicyFilterConfigToml,
465
+ >(&layers, CONFIG_TOML_FILE)
466
+ .await
467
+ {
468
+ return Err(io_error_from_config_error(
469
+ io::ErrorKind::InvalidData,
470
+ config_error,
471
+ /*source*/ None,
472
+ ));
473
+ }
474
+ return Err(err);
475
+ }
476
+
477
+ let config_layer_stack = ConfigLayerStack::new(
478
+ layers,
479
+ config_requirements_toml.clone().try_into()?,
480
+ config_requirements_toml.into_toml(),
481
+ )?
482
+ .with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules);
483
+ startup_warnings.extend(ignored_config_warning(
484
+ &config_layer_stack,
485
+ &requirements_layers,
486
+ ));
487
+ Ok(config_layer_stack.with_startup_warnings(startup_warnings))
488
+ }
489
+
490
+ async fn load_user_config_layer(
491
+ fs: &dyn ExecutorFileSystem,
492
+ user_file: &AbsolutePathBuf,
493
+ profile: Option<&ProfileV2Name>,
494
+ ignore_user_config: bool,
495
+ strict_config: bool,
496
+ ) -> io::Result<ConfigLayerEntry> {
497
+ let profile = profile.map(ToString::to_string);
498
+ if ignore_user_config {
499
+ return Ok(ConfigLayerEntry::new(
500
+ ConfigLayerSource::User {
501
+ file: user_file.clone(),
502
+ profile,
503
+ },
504
+ TomlValue::Table(toml::map::Map::new()),
505
+ ));
506
+ }
507
+
508
+ load_config_toml_for_required_layer(fs, user_file, strict_config, |config_toml| {
509
+ ConfigLayerEntry::new(
510
+ ConfigLayerSource::User {
511
+ file: user_file.clone(),
512
+ profile: profile.clone(),
513
+ },
514
+ config_toml,
515
+ )
516
+ })
517
+ .await
518
+ }
519
+
520
+ fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigLayerEntry) {
521
+ match layers
522
+ .iter()
523
+ .position(|existing| existing.name.precedence() > layer.name.precedence())
524
+ {
525
+ Some(index) => layers.insert(index, layer),
526
+ None => layers.push(layer),
527
+ }
528
+ }
529
+
530
+ /// Attempts to load a config.toml file from `config_toml`.
531
+ /// - If the file exists and is valid TOML, passes the parsed `toml::Value` to
532
+ /// `create_entry` and returns the resulting layer entry.
533
+ /// - If the file does not exist, uses an empty `Table` with `create_entry` and
534
+ /// returns the resulting layer entry.
535
+ /// - If there is an error reading the file or parsing the TOML, returns an
536
+ /// error.
537
+ async fn load_config_toml_for_required_layer(
538
+ fs: &dyn ExecutorFileSystem,
539
+ toml_file: &AbsolutePathBuf,
540
+ strict_config: bool,
541
+ create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
542
+ ) -> io::Result<ConfigLayerEntry> {
543
+ let loaded = load_config_toml_for_required_layer_raw(fs, toml_file, strict_config).await?;
544
+ let toml_value = resolve_relative_paths_in_config_toml(loaded.toml, loaded.base_dir.as_path())?;
545
+
546
+ Ok(create_entry(toml_value))
547
+ }
548
+
549
+ #[derive(Debug, Clone)]
550
+ struct LoadedTomlFile {
551
+ toml: TomlValue,
552
+ base_dir: AbsolutePathBuf,
553
+ }
554
+
555
+ async fn load_config_toml_for_required_layer_raw(
556
+ fs: &dyn ExecutorFileSystem,
557
+ toml_file: &AbsolutePathBuf,
558
+ strict_config: bool,
559
+ ) -> io::Result<LoadedTomlFile> {
560
+ let config_parent = toml_file.as_path().parent().ok_or_else(|| {
561
+ io::Error::new(
562
+ io::ErrorKind::InvalidData,
563
+ format!(
564
+ "Config file {} has no parent directory",
565
+ toml_file.as_path().display()
566
+ ),
567
+ )
568
+ })?;
569
+ let base_dir = AbsolutePathBuf::from_absolute_path(config_parent)?;
570
+ let toml_file_uri = PathUri::from_abs_path(toml_file);
571
+ let toml_value = match fs
572
+ .read_file_text(&toml_file_uri, Default::default(), /*sandbox*/ None)
573
+ .await
574
+ {
575
+ Ok(contents) => {
576
+ let config: TomlValue = toml::from_str(&contents).map_err(|err| {
577
+ let config_error =
578
+ config_error_from_toml(toml_file.as_path(), &contents, err.clone());
579
+ io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
580
+ })?;
581
+ if strict_config {
582
+ validate_config_toml_strictly(
583
+ toml_file.as_path(),
584
+ &contents,
585
+ &config,
586
+ config_parent,
587
+ )?;
588
+ }
589
+ Ok(config)
590
+ }
591
+ Err(e) => {
592
+ if e.kind() == io::ErrorKind::NotFound {
593
+ Ok(TomlValue::Table(toml::map::Map::new()))
594
+ } else {
595
+ Err(io::Error::new(
596
+ e.kind(),
597
+ format!(
598
+ "Failed to read config file {}: {e}",
599
+ toml_file.as_path().display()
600
+ ),
601
+ ))
602
+ }
603
+ }
604
+ }?;
605
+
606
+ Ok(LoadedTomlFile {
607
+ toml: toml_value,
608
+ base_dir,
609
+ })
610
+ }
611
+
612
+ fn validate_config_toml_strictly(
613
+ toml_file: &Path,
614
+ contents: &str,
615
+ value: &TomlValue,
616
+ base_dir: &Path,
617
+ ) -> io::Result<()> {
618
+ let _guard = AbsolutePathBufGuard::new(base_dir);
619
+ if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
620
+ toml_file,
621
+ contents,
622
+ value.clone(),
623
+ ) {
624
+ Err(io_error_from_config_error(
625
+ io::ErrorKind::InvalidData,
626
+ config_error,
627
+ /*source*/ None,
628
+ ))
629
+ } else {
630
+ Ok(())
631
+ }
632
+ }
633
+
634
+ fn validate_cli_overrides_strictly(
635
+ cli_overrides_layer: &TomlValue,
636
+ base_dir: &Path,
637
+ ) -> io::Result<()> {
638
+ let _guard = AbsolutePathBufGuard::new(base_dir);
639
+ if let Some(path) = ignored_toml_value_fields::<ConfigToml>(cli_overrides_layer.clone()).first()
640
+ {
641
+ let ignored_path = path.join(".");
642
+ return Err(io::Error::new(
643
+ io::ErrorKind::InvalidData,
644
+ format!("unknown configuration field `{ignored_path}` in -c/--config override"),
645
+ ));
646
+ }
647
+
648
+ if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) {
649
+ return Err(io::Error::new(
650
+ io::ErrorKind::InvalidData,
651
+ format!("unknown configuration field `{ignored_path}` in -c/--config override"),
652
+ ));
653
+ }
654
+
655
+ Ok(())
656
+ }
657
+
658
+ /// If available, load requirements from the platform's system `requirements.toml`
659
+ /// location as a requirements layer.
660
+ pub async fn load_requirements_toml(
661
+ fs: &dyn ExecutorFileSystem,
662
+ requirements_toml_file: &AbsolutePathBuf,
663
+ ) -> io::Result<Option<RequirementsLayerEntry>> {
664
+ let requirements_toml_file_uri = PathUri::from_abs_path(requirements_toml_file);
665
+ match fs
666
+ .read_file_text(
667
+ &requirements_toml_file_uri,
668
+ Default::default(),
669
+ /*sandbox*/ None,
670
+ )
671
+ .await
672
+ {
673
+ Ok(contents) => {
674
+ let requirements_parent = requirements_toml_file.parent().ok_or_else(|| {
675
+ io::Error::new(
676
+ io::ErrorKind::InvalidData,
677
+ format!(
678
+ "Requirements file {} has no parent directory",
679
+ requirements_toml_file.as_ref().display()
680
+ ),
681
+ )
682
+ })?;
683
+ let base_dir = AbsolutePathBuf::from_absolute_path(requirements_parent)?;
684
+ Ok(Some(
685
+ RequirementsLayerEntry::from_toml(
686
+ RequirementSource::SystemRequirementsToml {
687
+ file: requirements_toml_file.clone(),
688
+ },
689
+ contents,
690
+ )
691
+ .with_base_dir(base_dir),
692
+ ))
693
+ }
694
+ Err(e) => {
695
+ if e.kind() != io::ErrorKind::NotFound {
696
+ Err(io::Error::new(
697
+ e.kind(),
698
+ format!(
699
+ "Failed to read requirements file {}: {e}",
700
+ requirements_toml_file.as_path().display(),
701
+ ),
702
+ ))
703
+ } else {
704
+ Ok(None)
705
+ }
706
+ }
707
+ }
708
+ }
709
+
710
+ #[cfg(unix)]
711
+ fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
712
+ AbsolutePathBuf::from_absolute_path(Path::new("/etc/codex/requirements.toml"))
713
+ }
714
+
715
+ #[cfg(windows)]
716
+ fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
717
+ windows_system_requirements_toml_file()
718
+ }
719
+
720
+ fn system_requirements_toml_file_with_overrides(
721
+ overrides: &LoaderOverrides,
722
+ ) -> io::Result<AbsolutePathBuf> {
723
+ match &overrides.system_requirements_path {
724
+ Some(path) => AbsolutePathBuf::from_absolute_path(path),
725
+ None => system_requirements_toml_file(),
726
+ }
727
+ }
728
+
729
+ /// Check local managed configuration sources without loading or parsing configuration.
730
+ ///
731
+ /// Filesystem or managed-preference errors are returned so callers can conservatively avoid
732
+ /// assuming that administrator-controlled configuration is absent.
733
+ pub fn has_local_managed_configuration(codex_home: &Path) -> io::Result<bool> {
734
+ let system_requirements_file = system_requirements_toml_file()?;
735
+ has_local_managed_configuration_with_system_requirements_path(
736
+ codex_home,
737
+ system_requirements_file.as_path(),
738
+ )
739
+ }
740
+
741
+ fn has_local_managed_configuration_with_system_requirements_path(
742
+ codex_home: &Path,
743
+ system_requirements_path: &Path,
744
+ ) -> io::Result<bool> {
745
+ #[cfg(windows)]
746
+ let _ = codex_home;
747
+
748
+ #[cfg(not(windows))]
749
+ if layer_io::managed_config_default_path(codex_home).try_exists()? {
750
+ return Ok(true);
751
+ }
752
+
753
+ if system_requirements_path.try_exists()? {
754
+ return Ok(true);
755
+ }
756
+
757
+ #[cfg(target_os = "macos")]
758
+ if macos::has_managed_preferences()? {
759
+ return Ok(true);
760
+ }
761
+
762
+ Ok(false)
763
+ }
764
+
765
+ #[cfg(unix)]
766
+ pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
767
+ AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
768
+ }
769
+
770
+ #[cfg(windows)]
771
+ pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
772
+ windows_system_config_toml_file()
773
+ }
774
+
775
+ fn system_config_toml_file_with_overrides(
776
+ overrides: &LoaderOverrides,
777
+ ) -> io::Result<AbsolutePathBuf> {
778
+ match &overrides.system_config_path {
779
+ Some(path) => AbsolutePathBuf::from_absolute_path(path),
780
+ None => system_config_toml_file(),
781
+ }
782
+ }
783
+
784
+ #[cfg(windows)]
785
+ fn windows_codex_system_dir() -> PathBuf {
786
+ let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| {
787
+ tracing::warn!(
788
+ error = %err,
789
+ "Failed to resolve ProgramData known folder; using default path"
790
+ );
791
+ PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)
792
+ });
793
+ program_data.join("OpenAI").join("Codex")
794
+ }
795
+
796
+ #[cfg(windows)]
797
+ fn windows_system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
798
+ let requirements_toml_file = windows_codex_system_dir().join("requirements.toml");
799
+ AbsolutePathBuf::try_from(requirements_toml_file)
800
+ }
801
+
802
+ #[cfg(windows)]
803
+ fn windows_system_config_toml_file() -> io::Result<AbsolutePathBuf> {
804
+ let config_toml_file = windows_codex_system_dir().join("config.toml");
805
+ AbsolutePathBuf::try_from(config_toml_file)
806
+ }
807
+
808
+ #[cfg(windows)]
809
+ fn windows_program_data_dir_from_known_folder() -> io::Result<PathBuf> {
810
+ use std::ffi::OsString;
811
+ use std::os::windows::ffi::OsStringExt;
812
+ use windows_sys::Win32::System::Com::CoTaskMemFree;
813
+ use windows_sys::Win32::UI::Shell::FOLDERID_ProgramData;
814
+ use windows_sys::Win32::UI::Shell::KF_FLAG_DEFAULT;
815
+ use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath;
816
+
817
+ let mut path_ptr = std::ptr::null_mut::<u16>();
818
+ let known_folder_flags = u32::try_from(KF_FLAG_DEFAULT).map_err(|_| {
819
+ io::Error::other(format!(
820
+ "KF_FLAG_DEFAULT did not fit in u32: {KF_FLAG_DEFAULT}"
821
+ ))
822
+ })?;
823
+ // Known folder IDs reference:
824
+ // https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
825
+ // SAFETY: SHGetKnownFolderPath initializes path_ptr with a CoTaskMem-allocated,
826
+ // null-terminated UTF-16 string on success.
827
+ let hr = unsafe {
828
+ SHGetKnownFolderPath(&FOLDERID_ProgramData, known_folder_flags, 0, &mut path_ptr)
829
+ };
830
+ if hr != 0 {
831
+ return Err(io::Error::other(format!(
832
+ "SHGetKnownFolderPath(FOLDERID_ProgramData) failed with HRESULT {hr:#010x}"
833
+ )));
834
+ }
835
+ if path_ptr.is_null() {
836
+ return Err(io::Error::other(
837
+ "SHGetKnownFolderPath(FOLDERID_ProgramData) returned a null pointer",
838
+ ));
839
+ }
840
+
841
+ // SAFETY: path_ptr is a valid null-terminated UTF-16 string allocated by
842
+ // SHGetKnownFolderPath and must be freed with CoTaskMemFree.
843
+ let path = unsafe {
844
+ let mut len = 0usize;
845
+ while *path_ptr.add(len) != 0 {
846
+ len += 1;
847
+ }
848
+ let wide = std::slice::from_raw_parts(path_ptr, len);
849
+ let path = PathBuf::from(OsString::from_wide(wide));
850
+ CoTaskMemFree(path_ptr.cast());
851
+ path
852
+ };
853
+
854
+ Ok(path)
855
+ }
856
+
857
+ fn requirements_layers_from_legacy_scheme(
858
+ loaded_config_layers: LoadedConfigLayers,
859
+ codex_home: &Path,
860
+ ) -> io::Result<Vec<RequirementsLayerEntry>> {
861
+ // List the file-backed legacy layer first because requirements layers are
862
+ // composed lowest-precedence to highest-precedence, and MDM has higher
863
+ // precedence than the legacy managed_config.toml file.
864
+ let LoadedConfigLayers {
865
+ managed_config,
866
+ managed_config_from_mdm,
867
+ ..
868
+ } = loaded_config_layers;
869
+
870
+ let layer_count =
871
+ usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some());
872
+ let mut layers = Vec::with_capacity(layer_count);
873
+ let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?;
874
+ for (source, config, base_dir) in managed_config
875
+ .map(|c| {
876
+ let base_dir = c.file.parent().ok_or_else(|| {
877
+ io::Error::new(
878
+ io::ErrorKind::InvalidData,
879
+ format!(
880
+ "Managed config file {} has no parent directory",
881
+ c.file.as_path().display()
882
+ ),
883
+ )
884
+ })?;
885
+ Ok::<_, io::Error>((
886
+ RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file },
887
+ c.managed_config,
888
+ base_dir,
889
+ ))
890
+ })
891
+ .transpose()?
892
+ .into_iter()
893
+ .chain(managed_config_from_mdm.map(|config| {
894
+ (
895
+ RequirementSource::LegacyManagedConfigTomlFromMdm,
896
+ config.managed_config,
897
+ codex_home.clone(),
898
+ )
899
+ }))
900
+ {
901
+ let legacy_config: LegacyManagedConfigToml =
902
+ config.try_into().map_err(|err: toml::de::Error| {
903
+ io::Error::new(
904
+ io::ErrorKind::InvalidData,
905
+ format!("Failed to parse config requirements as TOML: {err}"),
906
+ )
907
+ })?;
908
+
909
+ layers.push(
910
+ RequirementsLayerEntry::from_toml_value(
911
+ source,
912
+ legacy_requirements_to_toml_value(legacy_config)?,
913
+ )
914
+ .with_base_dir(base_dir),
915
+ );
916
+ }
917
+
918
+ Ok(layers)
919
+ }
920
+
921
+ fn legacy_requirements_to_toml_value(legacy: LegacyManagedConfigToml) -> io::Result<TomlValue> {
922
+ let LegacyManagedConfigToml {
923
+ approval_policy,
924
+ approvals_reviewer,
925
+ sandbox_mode,
926
+ } = legacy;
927
+ let mut table = toml::map::Map::new();
928
+ if let Some(approval_policy) = approval_policy {
929
+ table.insert(
930
+ "allowed_approval_policies".to_string(),
931
+ toml_value_from_serializable(vec![approval_policy])?,
932
+ );
933
+ }
934
+ if let Some(approvals_reviewer) = approvals_reviewer {
935
+ let mut allowed_reviewers = vec![approvals_reviewer];
936
+ if approvals_reviewer == ApprovalsReviewer::AutoReview {
937
+ allowed_reviewers.push(ApprovalsReviewer::User);
938
+ }
939
+ table.insert(
940
+ "allowed_approvals_reviewers".to_string(),
941
+ toml_value_from_serializable(allowed_reviewers)?,
942
+ );
943
+ }
944
+ if let Some(sandbox_mode) = sandbox_mode {
945
+ let required_mode: SandboxModeRequirement = sandbox_mode.into();
946
+ // Allowing read-only is a requirement for Codex to function correctly.
947
+ // So in this backfill path, we append read-only if it's not already specified.
948
+ let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly];
949
+ if required_mode != SandboxModeRequirement::ReadOnly {
950
+ allowed_modes.push(required_mode);
951
+ }
952
+ table.insert(
953
+ "allowed_sandbox_modes".to_string(),
954
+ toml_value_from_serializable(allowed_modes)?,
955
+ );
956
+ }
957
+ Ok(TomlValue::Table(table))
958
+ }
959
+
960
+ fn toml_value_from_serializable<T: serde::Serialize>(value: T) -> io::Result<TomlValue> {
961
+ TomlValue::try_from(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
962
+ }
963
+
964
+ #[derive(Clone, Copy, PartialEq, Eq)]
965
+ enum CredentialBrokerProjectState {
966
+ Unconfigured,
967
+ Disabled,
968
+ Enabled,
969
+ }
970
+
971
+ fn apply_credential_broker_requirements(
972
+ context: &mut ProjectTrustContext,
973
+ requirements: &crate::ConfigRequirementsWithSources,
974
+ ) {
975
+ if context.credential_broker == CredentialBrokerProjectState::Unconfigured {
976
+ return;
977
+ }
978
+ let enabled = requirements
979
+ .feature_requirements
980
+ .as_ref()
981
+ .and_then(|requirements| requirements.entries.get("network_proxy"))
982
+ .copied()
983
+ .unwrap_or(context.credential_broker == CredentialBrokerProjectState::Enabled);
984
+ context.credential_broker = if enabled
985
+ && requirements
986
+ .network
987
+ .as_ref()
988
+ .is_none_or(|network| network.enabled != Some(false))
989
+ {
990
+ CredentialBrokerProjectState::Enabled
991
+ } else {
992
+ CredentialBrokerProjectState::Disabled
993
+ };
994
+ }
995
+
996
+ struct ProjectTrustContext {
997
+ project_root: AbsolutePathBuf,
998
+ project_root_key: String,
999
+ project_root_lookup_keys: Vec<String>,
1000
+ checkout_root: Option<AbsolutePathBuf>,
1001
+ repo_root: Option<AbsolutePathBuf>,
1002
+ repo_root_key: Option<String>,
1003
+ repo_root_lookup_keys: Option<Vec<String>>,
1004
+ projects_trust: std::collections::HashMap<String, TrustLevel>,
1005
+ user_config_file: AbsolutePathBuf,
1006
+ credential_broker: CredentialBrokerProjectState,
1007
+ credential_broker_provider_env_keys: Vec<String>,
1008
+ }
1009
+
1010
+ #[derive(Deserialize)]
1011
+ struct ProjectTrustConfigToml {
1012
+ projects: Option<std::collections::HashMap<String, ProjectConfig>>,
1013
+ }
1014
+
1015
+ struct ProjectTrustDecision {
1016
+ trust_level: Option<TrustLevel>,
1017
+ trust_key: String,
1018
+ }
1019
+
1020
+ impl ProjectTrustDecision {
1021
+ fn is_trusted(&self) -> bool {
1022
+ matches!(self.trust_level, Some(TrustLevel::Trusted))
1023
+ }
1024
+ }
1025
+
1026
+ impl ProjectTrustContext {
1027
+ fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision {
1028
+ for dir_key in normalized_project_trust_keys(dir.as_path()) {
1029
+ if let Some((trust_key, trust_level)) =
1030
+ project_trust_for_lookup_key(&self.projects_trust, &dir_key)
1031
+ {
1032
+ return ProjectTrustDecision {
1033
+ trust_level: Some(trust_level),
1034
+ trust_key,
1035
+ };
1036
+ }
1037
+ }
1038
+
1039
+ for project_root_key in &self.project_root_lookup_keys {
1040
+ if let Some((trust_key, trust_level)) =
1041
+ project_trust_for_lookup_key(&self.projects_trust, project_root_key)
1042
+ {
1043
+ return ProjectTrustDecision {
1044
+ trust_level: Some(trust_level),
1045
+ trust_key,
1046
+ };
1047
+ }
1048
+ }
1049
+
1050
+ if let Some(repo_root_lookup_keys) = self.repo_root_lookup_keys.as_ref() {
1051
+ for repo_root_key in repo_root_lookup_keys {
1052
+ if let Some((trust_key, trust_level)) =
1053
+ project_trust_for_lookup_key(&self.projects_trust, repo_root_key)
1054
+ {
1055
+ return ProjectTrustDecision {
1056
+ trust_level: Some(trust_level),
1057
+ trust_key,
1058
+ };
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ ProjectTrustDecision {
1064
+ trust_level: None,
1065
+ trust_key: self
1066
+ .repo_root_key
1067
+ .clone()
1068
+ .unwrap_or_else(|| self.project_root_key.clone()),
1069
+ }
1070
+ }
1071
+
1072
+ fn disabled_reason_for_decision(&self, decision: &ProjectTrustDecision) -> Option<String> {
1073
+ if decision.is_trusted() {
1074
+ return None;
1075
+ }
1076
+
1077
+ let gated_features = "project-local config, hooks, and exec policies";
1078
+ let trust_key = decision.trust_key.as_str();
1079
+ let user_config_file = self.user_config_file.as_path().display();
1080
+ // Trust may come from managed config. Keep the explicit-untrusted prefix
1081
+ // stable because the remote TUI uses it to recognize existing decisions.
1082
+ match decision.trust_level {
1083
+ Some(TrustLevel::Untrusted) => Some(format!(
1084
+ "{trust_key} is marked as untrusted in the effective configuration. To load {gated_features}, update its trust setting. If that setting is managed by your organization, contact your administrator."
1085
+ )),
1086
+ _ => Some(format!(
1087
+ "To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}."
1088
+ )),
1089
+ }
1090
+ }
1091
+
1092
+ fn root_checkout_hooks_folder_for_dir(&self, dir: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
1093
+ let checkout_root = self.checkout_root.as_ref()?;
1094
+ let repo_root = self.repo_root.as_ref()?;
1095
+ // Regular checkouts resolve both paths to the same root; linked worktrees do not.
1096
+ if checkout_root == repo_root {
1097
+ return None;
1098
+ }
1099
+
1100
+ let relative_dir = dir.as_path().strip_prefix(checkout_root.as_path()).ok()?;
1101
+ Some(repo_root.join(relative_dir).join(".codex"))
1102
+ }
1103
+ }
1104
+
1105
+ fn project_layer_entry(
1106
+ dot_codex_folder: &AbsolutePathBuf,
1107
+ config: TomlValue,
1108
+ disabled_reason: Option<String>,
1109
+ hooks_config_folder_override: Option<AbsolutePathBuf>,
1110
+ ) -> ConfigLayerEntry {
1111
+ let source = ConfigLayerSource::Project {
1112
+ dot_codex_folder: dot_codex_folder.clone(),
1113
+ };
1114
+
1115
+ let entry = if let Some(reason) = disabled_reason {
1116
+ ConfigLayerEntry::new_disabled(source, config, reason)
1117
+ } else {
1118
+ ConfigLayerEntry::new(source, config)
1119
+ };
1120
+ entry.with_hooks_config_folder_override(hooks_config_folder_override)
1121
+ }
1122
+
1123
+ fn sanitize_project_config(
1124
+ config: &mut TomlValue,
1125
+ credential_broker: CredentialBrokerProjectState,
1126
+ trusted_provider_env_keys: &[String],
1127
+ ) -> Vec<String> {
1128
+ let Some(table) = config.as_table_mut() else {
1129
+ return Vec::new();
1130
+ };
1131
+
1132
+ let credential_broker_configured =
1133
+ credential_broker != CredentialBrokerProjectState::Unconfigured;
1134
+ let mut ignored_keys = Vec::new();
1135
+ for key in PROJECT_LOCAL_CONFIG_DENYLIST {
1136
+ if table.remove(*key).is_some() {
1137
+ ignored_keys.push((*key).to_string());
1138
+ }
1139
+ }
1140
+ if let Some(features) = table.get_mut("features").and_then(TomlValue::as_table_mut) {
1141
+ if credential_broker == CredentialBrokerProjectState::Enabled
1142
+ && features.remove("shell_snapshot").is_some()
1143
+ {
1144
+ ignored_keys.push("features.shell_snapshot".to_string());
1145
+ }
1146
+ if features.remove("respect_system_proxy").is_some() {
1147
+ ignored_keys.push("features.respect_system_proxy".to_string());
1148
+ }
1149
+ if credential_broker_configured
1150
+ && features
1151
+ .get("network_proxy")
1152
+ .is_some_and(|network_proxy| network_proxy.as_bool().is_some())
1153
+ {
1154
+ features.remove("network_proxy");
1155
+ ignored_keys.push("features.network_proxy".to_string());
1156
+ } else if let Some(network_proxy) = features
1157
+ .get_mut("network_proxy")
1158
+ .and_then(TomlValue::as_table_mut)
1159
+ {
1160
+ if network_proxy.remove("credential_broker").is_some() {
1161
+ ignored_keys.push("features.network_proxy.credential_broker".to_string());
1162
+ }
1163
+ if network_proxy.remove("credentials").is_some() {
1164
+ ignored_keys.push("features.network_proxy.credentials".to_string());
1165
+ }
1166
+ if credential_broker_configured && network_proxy.remove("enabled").is_some() {
1167
+ ignored_keys.push("features.network_proxy.enabled".to_string());
1168
+ }
1169
+ }
1170
+ }
1171
+ if credential_broker == CredentialBrokerProjectState::Enabled
1172
+ && let Some(policy) = table
1173
+ .get_mut("shell_environment_policy")
1174
+ .and_then(TomlValue::as_table_mut)
1175
+ {
1176
+ if policy.remove("experimental_use_profile").is_some() {
1177
+ ignored_keys.push("shell_environment_policy.experimental_use_profile".to_string());
1178
+ }
1179
+ let environment_keys_match = |left: &str, right: &str| {
1180
+ left == right || cfg!(windows) && left.eq_ignore_ascii_case(right)
1181
+ };
1182
+ if let Some(overrides) = policy.get_mut("set").and_then(TomlValue::as_table_mut) {
1183
+ overrides.retain(|key, _| {
1184
+ if key.eq_ignore_ascii_case("ZDOTDIR")
1185
+ || key.eq_ignore_ascii_case("BASH_ENV")
1186
+ || is_credential_broker_provider_env_key(key)
1187
+ || trusted_provider_env_keys
1188
+ .iter()
1189
+ .any(|provider_key| environment_keys_match(key, provider_key))
1190
+ {
1191
+ ignored_keys.push(format!("shell_environment_policy.set.{key}"));
1192
+ false
1193
+ } else {
1194
+ true
1195
+ }
1196
+ });
1197
+ }
1198
+ }
1199
+ // Repository contents must not turn an ordinary key into a permission increase.
1200
+ if let Some(chat) = table
1201
+ .get_mut("tui")
1202
+ .and_then(|tui| tui.get_mut("keymap"))
1203
+ .and_then(|keymap| keymap.get_mut("chat"))
1204
+ .and_then(TomlValue::as_table_mut)
1205
+ {
1206
+ for key in ["previous_permission_mode", "next_permission_mode"] {
1207
+ if chat.remove(key).is_some() {
1208
+ ignored_keys.push(format!("tui.keymap.chat.{key}"));
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ ignored_keys
1214
+ }
1215
+
1216
+ fn project_ignored_config_keys_warning(
1217
+ dot_codex_folder: &AbsolutePathBuf,
1218
+ ignored_keys: &[String],
1219
+ ) -> String {
1220
+ let config_path = dot_codex_folder.join(CONFIG_TOML_FILE);
1221
+ let ignored_keys = ignored_keys.join(", ");
1222
+ format!(
1223
+ concat!(
1224
+ "Ignored unsupported project-local config keys in {config_path}: {ignored_keys}. ",
1225
+ "If you want these settings to apply, manually set them in your ",
1226
+ "user-level config.toml."
1227
+ ),
1228
+ config_path = config_path.display(),
1229
+ ignored_keys = ignored_keys,
1230
+ )
1231
+ }
1232
+
1233
+ async fn project_trust_context(
1234
+ fs: &dyn ExecutorFileSystem,
1235
+ merged_config: &TomlValue,
1236
+ trusted_broker_config: &TomlValue,
1237
+ cwd: &AbsolutePathBuf,
1238
+ project_root_markers: &[String],
1239
+ config_base_dir: &Path,
1240
+ user_config_file: &AbsolutePathBuf,
1241
+ ) -> io::Result<ProjectTrustContext> {
1242
+ let project_trust_config: ProjectTrustConfigToml = {
1243
+ let _guard = AbsolutePathBufGuard::new(config_base_dir);
1244
+ merged_config
1245
+ .clone()
1246
+ .try_into()
1247
+ .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
1248
+ };
1249
+
1250
+ let project_root = find_project_root(fs, cwd, project_root_markers).await?;
1251
+ let projects = project_trust_config.projects.unwrap_or_default();
1252
+
1253
+ let project_root_lookup_keys = normalized_project_trust_keys(project_root.as_path());
1254
+ let project_root_key = project_root_lookup_keys
1255
+ .first()
1256
+ .cloned()
1257
+ .unwrap_or_else(|| project_trust_key(project_root.as_path()));
1258
+ let checkout_root = find_git_checkout_root(fs, cwd).await;
1259
+ let repo_root = resolve_root_git_project_for_trust(fs, cwd).await;
1260
+ let repo_root_lookup_keys = repo_root
1261
+ .as_ref()
1262
+ .map(|root| normalized_project_trust_keys(root.as_path()));
1263
+ let repo_root_key = repo_root_lookup_keys
1264
+ .as_ref()
1265
+ .and_then(|keys| keys.first().cloned());
1266
+
1267
+ let projects_trust = projects
1268
+ .into_iter()
1269
+ .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level)))
1270
+ .collect();
1271
+ let network_proxy = trusted_broker_config
1272
+ .get("features")
1273
+ .and_then(|features| features.get("network_proxy"))
1274
+ .and_then(TomlValue::as_table);
1275
+ let credential_broker_configured = network_proxy.is_some_and(|network_proxy| {
1276
+ network_proxy.get("credential_broker") == Some(&TomlValue::Boolean(true))
1277
+ });
1278
+ let credential_broker = if credential_broker_configured {
1279
+ if network_proxy
1280
+ .and_then(|network_proxy| network_proxy.get("enabled"))
1281
+ .and_then(TomlValue::as_bool)
1282
+ .unwrap_or(false)
1283
+ {
1284
+ CredentialBrokerProjectState::Enabled
1285
+ } else {
1286
+ CredentialBrokerProjectState::Disabled
1287
+ }
1288
+ } else {
1289
+ CredentialBrokerProjectState::Unconfigured
1290
+ };
1291
+ let configured_providers = network_proxy
1292
+ .and_then(|network_proxy| network_proxy.get("credentials"))
1293
+ .and_then(TomlValue::as_table);
1294
+ let credential_broker_provider_env_keys = configured_providers
1295
+ .into_iter()
1296
+ .flat_map(|providers| providers.values())
1297
+ .flat_map(|provider| {
1298
+ provider
1299
+ .get("env")
1300
+ .and_then(TomlValue::as_array)
1301
+ .into_iter()
1302
+ .flatten()
1303
+ .filter_map(TomlValue::as_str)
1304
+ .chain(
1305
+ provider
1306
+ .get("url_prefix_from_env")
1307
+ .and_then(TomlValue::as_str),
1308
+ )
1309
+ .map(str::to_string)
1310
+ })
1311
+ .collect::<Vec<_>>();
1312
+
1313
+ Ok(ProjectTrustContext {
1314
+ project_root,
1315
+ project_root_key,
1316
+ project_root_lookup_keys,
1317
+ checkout_root,
1318
+ repo_root,
1319
+ repo_root_key,
1320
+ repo_root_lookup_keys,
1321
+ projects_trust,
1322
+ user_config_file: user_config_file.clone(),
1323
+ credential_broker,
1324
+ credential_broker_provider_env_keys,
1325
+ })
1326
+ }
1327
+
1328
+ // Start before managed layers are merged: replaying credential source remaps
1329
+ // can discard provider fields needed to protect their environment bindings.
1330
+ fn credential_broker_trusted_config(
1331
+ merged_config: &TomlValue,
1332
+ thread_config_layers: &[ConfigLayerEntry],
1333
+ loaded_config_layers: &LoadedConfigLayers,
1334
+ ) -> TomlValue {
1335
+ let mut trusted_config = merged_config.clone();
1336
+ for layer in thread_config_layers {
1337
+ merge_toml_values(&mut trusted_config, &layer.config);
1338
+ }
1339
+ if let Some(config) = loaded_config_layers.managed_config.as_ref() {
1340
+ merge_toml_values(&mut trusted_config, &config.managed_config);
1341
+ }
1342
+ if let Some(config) = loaded_config_layers.managed_config_from_mdm.as_ref() {
1343
+ merge_toml_values(&mut trusted_config, &config.managed_config);
1344
+ }
1345
+ trusted_config
1346
+ }
1347
+
1348
+ /// Canonicalize the path and convert it to a string to be used as a key in the
1349
+ /// projects trust map. On Windows, strips UNC, when possible, to try to ensure
1350
+ /// that different paths that point to the same location have the same key.
1351
+ pub fn project_trust_key(path: &Path) -> String {
1352
+ normalized_project_trust_keys(path)
1353
+ .into_iter()
1354
+ .next()
1355
+ .unwrap_or_else(|| normalize_project_trust_lookup_key(path.to_string_lossy().to_string()))
1356
+ }
1357
+
1358
+ /// Returns canonical and original path spellings in trust-lookup precedence order.
1359
+ pub fn normalized_project_trust_keys(path: &Path) -> Vec<String> {
1360
+ let normalized_path = normalize_project_trust_lookup_key(path.to_string_lossy().to_string());
1361
+ let normalized_canonical_path = normalize_project_trust_lookup_key(
1362
+ normalize_path(path)
1363
+ .unwrap_or_else(|_| path.to_path_buf())
1364
+ .to_string_lossy()
1365
+ .to_string(),
1366
+ );
1367
+ if normalized_path == normalized_canonical_path {
1368
+ vec![normalized_canonical_path]
1369
+ } else {
1370
+ vec![normalized_canonical_path, normalized_path]
1371
+ }
1372
+ }
1373
+
1374
+ fn normalize_project_trust_lookup_key(key: String) -> String {
1375
+ if cfg!(windows) {
1376
+ key.to_ascii_lowercase()
1377
+ } else {
1378
+ key
1379
+ }
1380
+ }
1381
+ fn project_trust_for_lookup_key(
1382
+ projects_trust: &std::collections::HashMap<String, TrustLevel>,
1383
+ lookup_key: &str,
1384
+ ) -> Option<(String, TrustLevel)> {
1385
+ if let Some(trust_level) = projects_trust.get(lookup_key).copied() {
1386
+ return Some((lookup_key.to_string(), trust_level));
1387
+ }
1388
+
1389
+ let mut normalized_matches: Vec<_> = projects_trust
1390
+ .iter()
1391
+ .filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key)
1392
+ .collect();
1393
+ normalized_matches.sort_by_key(|(key, _)| *key);
1394
+ normalized_matches
1395
+ .first()
1396
+ .map(|(key, trust_level)| ((**key).clone(), **trust_level))
1397
+ }
1398
+ /// Takes a `toml::Value` parsed from a config.toml file and walks through it,
1399
+ /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new
1400
+ /// `toml::Value` with the same shape but with paths resolved.
1401
+ ///
1402
+ /// This ensures that multiple config layers can be merged together correctly
1403
+ /// even if they were loaded from different directories.
1404
+ #[doc(hidden)]
1405
+ pub fn resolve_relative_paths_in_config_toml(
1406
+ value_from_config_toml: TomlValue,
1407
+ base_dir: &Path,
1408
+ ) -> io::Result<TomlValue> {
1409
+ // Use the serialize/deserialize round-trip to convert the
1410
+ // `toml::Value` into a `ConfigToml` with `AbsolutePath
1411
+ let _guard = AbsolutePathBufGuard::new(base_dir);
1412
+ let Ok(resolved) = value_from_config_toml.clone().try_into::<ConfigToml>() else {
1413
+ return Ok(value_from_config_toml);
1414
+ };
1415
+ drop(_guard);
1416
+
1417
+ let resolved_value = TomlValue::try_from(resolved).map_err(|e| {
1418
+ io::Error::new(
1419
+ io::ErrorKind::InvalidData,
1420
+ format!("Failed to serialize resolved config: {e}"),
1421
+ )
1422
+ })?;
1423
+
1424
+ Ok(copy_shape_from_original(
1425
+ &value_from_config_toml,
1426
+ &resolved_value,
1427
+ ))
1428
+ }
1429
+
1430
+ /// Ensure that every field in `original` is present in the returned
1431
+ /// `toml::Value`, taking the value from `resolved` where possible. This ensures
1432
+ /// the fields that we "removed" during the serialize/deserialize round-trip in
1433
+ /// `resolve_config_paths` are preserved, out of an abundance of caution.
1434
+ fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue {
1435
+ match (original, resolved) {
1436
+ (TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => {
1437
+ let mut table = toml::map::Map::new();
1438
+ for (key, original_value) in original_table {
1439
+ let resolved_value = resolved_table.get(key).unwrap_or(original_value);
1440
+ table.insert(
1441
+ key.clone(),
1442
+ copy_shape_from_original(original_value, resolved_value),
1443
+ );
1444
+ }
1445
+ TomlValue::Table(table)
1446
+ }
1447
+ (TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => {
1448
+ let mut items = Vec::new();
1449
+ for (index, original_value) in original_array.iter().enumerate() {
1450
+ let resolved_value = resolved_array.get(index).unwrap_or(original_value);
1451
+ items.push(copy_shape_from_original(original_value, resolved_value));
1452
+ }
1453
+ TomlValue::Array(items)
1454
+ }
1455
+ (_, resolved_value) => resolved_value.clone(),
1456
+ }
1457
+ }
1458
+
1459
+ /// Finds the nearest ancestor with a configured project marker, or returns `cwd`.
1460
+ /// Callers must use markers from configuration loaded before project layers.
1461
+ pub async fn find_project_root(
1462
+ fs: &dyn ExecutorFileSystem,
1463
+ cwd: &AbsolutePathBuf,
1464
+ project_root_markers: &[String],
1465
+ ) -> io::Result<AbsolutePathBuf> {
1466
+ if project_root_markers.is_empty() {
1467
+ return Ok(cwd.clone());
1468
+ }
1469
+
1470
+ for ancestor in cwd.ancestors() {
1471
+ for marker in project_root_markers {
1472
+ let marker_path = ancestor.join(marker);
1473
+ let marker_path_uri = PathUri::from_abs_path(&marker_path);
1474
+ let Ok(metadata) = fs
1475
+ .get_metadata(&marker_path_uri, Default::default(), /*sandbox*/ None)
1476
+ .await
1477
+ else {
1478
+ continue;
1479
+ };
1480
+ if marker == ".git"
1481
+ && metadata.is_directory
1482
+ && fs
1483
+ .get_metadata(
1484
+ &PathUri::from_abs_path(&marker_path.join("HEAD")),
1485
+ Default::default(),
1486
+ /*sandbox*/ None,
1487
+ )
1488
+ .await
1489
+ .is_err()
1490
+ {
1491
+ continue;
1492
+ }
1493
+ return Ok(ancestor);
1494
+ }
1495
+ }
1496
+ Ok(cwd.clone())
1497
+ }
1498
+
1499
+ async fn find_git_checkout_root(
1500
+ fs: &dyn ExecutorFileSystem,
1501
+ cwd: &AbsolutePathBuf,
1502
+ ) -> Option<AbsolutePathBuf> {
1503
+ let cwd_uri = PathUri::from_abs_path(cwd);
1504
+ let base = match fs
1505
+ .get_metadata(&cwd_uri, Default::default(), /*sandbox*/ None)
1506
+ .await
1507
+ {
1508
+ Ok(metadata) if metadata.is_directory => cwd.clone(),
1509
+ _ => cwd.parent()?,
1510
+ };
1511
+
1512
+ for dir in base.ancestors() {
1513
+ let dot_git = dir.join(".git");
1514
+ let dot_git_uri = PathUri::from_abs_path(&dot_git);
1515
+ let Ok(metadata) = fs
1516
+ .get_metadata(&dot_git_uri, Default::default(), /*sandbox*/ None)
1517
+ .await
1518
+ else {
1519
+ continue;
1520
+ };
1521
+ if metadata.is_directory
1522
+ && fs
1523
+ .get_metadata(
1524
+ &PathUri::from_abs_path(&dot_git.join("HEAD")),
1525
+ Default::default(),
1526
+ /*sandbox*/ None,
1527
+ )
1528
+ .await
1529
+ .is_err()
1530
+ {
1531
+ continue;
1532
+ }
1533
+ return Some(dir);
1534
+ }
1535
+ None
1536
+ }
1537
+
1538
+ struct LoadedProjectLayers {
1539
+ layers: Vec<ConfigLayerEntry>,
1540
+ startup_warnings: Vec<String>,
1541
+ }
1542
+
1543
+ #[derive(Debug, Clone)]
1544
+ struct DiscoveredProjectLayer {
1545
+ dot_codex_folder: AbsolutePathBuf,
1546
+ config: TomlValue,
1547
+ disabled_reason: Option<String>,
1548
+ hooks_config_folder_override: Option<AbsolutePathBuf>,
1549
+ load_root_checkout_hooks: bool,
1550
+ }
1551
+
1552
+ struct DiscoveredProjectLayers {
1553
+ layers: Vec<DiscoveredProjectLayer>,
1554
+ startup_warnings: Vec<String>,
1555
+ }
1556
+
1557
+ /// Return the appropriate list of layers (each with
1558
+ /// [ConfigLayerSource::Project] as the source) between `cwd` and
1559
+ /// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
1560
+ /// starting from folders closest to `project_root` (which is the lowest
1561
+ /// precedence) to those closest to `cwd` (which is the highest precedence).
1562
+ /// Any warnings are stack-level startup messages, not additional config layers.
1563
+ async fn load_project_layers(
1564
+ fs: &dyn ExecutorFileSystem,
1565
+ cwd: &AbsolutePathBuf,
1566
+ project_root: &AbsolutePathBuf,
1567
+ trust_context: &ProjectTrustContext,
1568
+ codex_home: &Path,
1569
+ strict_config: bool,
1570
+ ) -> io::Result<LoadedProjectLayers> {
1571
+ let discovered = discover_project_layers(
1572
+ fs,
1573
+ cwd,
1574
+ project_root,
1575
+ trust_context,
1576
+ codex_home,
1577
+ strict_config,
1578
+ )
1579
+ .await?;
1580
+ let mut layers = Vec::with_capacity(discovered.layers.len());
1581
+ for layer in discovered.layers {
1582
+ let config =
1583
+ resolve_relative_paths_in_config_toml(layer.config, layer.dot_codex_folder.as_path())?;
1584
+ let config = if layer.load_root_checkout_hooks {
1585
+ merge_root_checkout_project_hooks(
1586
+ fs,
1587
+ config,
1588
+ layer.hooks_config_folder_override.as_ref(),
1589
+ layer.disabled_reason.is_none(),
1590
+ )
1591
+ .await?
1592
+ } else {
1593
+ config
1594
+ };
1595
+ layers.push(project_layer_entry(
1596
+ &layer.dot_codex_folder,
1597
+ config,
1598
+ layer.disabled_reason,
1599
+ layer.hooks_config_folder_override,
1600
+ ));
1601
+ }
1602
+
1603
+ Ok(LoadedProjectLayers {
1604
+ layers,
1605
+ startup_warnings: discovered.startup_warnings,
1606
+ })
1607
+ }
1608
+
1609
+ async fn discover_project_layers(
1610
+ fs: &dyn ExecutorFileSystem,
1611
+ cwd: &AbsolutePathBuf,
1612
+ project_root: &AbsolutePathBuf,
1613
+ trust_context: &ProjectTrustContext,
1614
+ codex_home: &Path,
1615
+ strict_config: bool,
1616
+ ) -> io::Result<DiscoveredProjectLayers> {
1617
+ let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
1618
+ let codex_home_normalized =
1619
+ normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf());
1620
+ let mut dirs = cwd
1621
+ .ancestors()
1622
+ .scan(false, |done, a| {
1623
+ if *done {
1624
+ None
1625
+ } else {
1626
+ if &a == project_root {
1627
+ *done = true;
1628
+ }
1629
+ Some(a)
1630
+ }
1631
+ })
1632
+ .collect::<Vec<_>>();
1633
+ dirs.reverse();
1634
+
1635
+ let mut layers = Vec::new();
1636
+ let mut startup_warnings = Vec::new();
1637
+ for dir in dirs {
1638
+ let dot_codex_abs = dir.join(".codex");
1639
+ let dot_codex_uri = PathUri::from_abs_path(&dot_codex_abs);
1640
+ if !fs
1641
+ .get_metadata(&dot_codex_uri, Default::default(), /*sandbox*/ None)
1642
+ .await
1643
+ .map(|metadata| metadata.is_directory)
1644
+ .unwrap_or(false)
1645
+ {
1646
+ continue;
1647
+ }
1648
+
1649
+ let decision = trust_context.decision_for_dir(&dir);
1650
+ let disabled_reason = trust_context.disabled_reason_for_decision(&decision);
1651
+ let hooks_config_folder_override = trust_context.root_checkout_hooks_folder_for_dir(&dir);
1652
+ let dot_codex_normalized =
1653
+ normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
1654
+ if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
1655
+ continue;
1656
+ }
1657
+ let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
1658
+ let config_file_uri = PathUri::from_abs_path(&config_file);
1659
+ match fs
1660
+ .read_file_text(&config_file_uri, Default::default(), /*sandbox*/ None)
1661
+ .await
1662
+ {
1663
+ Ok(contents) => {
1664
+ let config: TomlValue = match toml::from_str(&contents) {
1665
+ Ok(config) => config,
1666
+ Err(e) => {
1667
+ if decision.is_trusted() {
1668
+ let config_file_display = config_file.as_path().display();
1669
+ return Err(io::Error::new(
1670
+ io::ErrorKind::InvalidData,
1671
+ format!(
1672
+ "Error parsing project config file {config_file_display}: {e}"
1673
+ ),
1674
+ ));
1675
+ }
1676
+ layers.push(DiscoveredProjectLayer {
1677
+ dot_codex_folder: dot_codex_abs,
1678
+ config: TomlValue::Table(toml::map::Map::new()),
1679
+ disabled_reason,
1680
+ hooks_config_folder_override,
1681
+ load_root_checkout_hooks: false,
1682
+ });
1683
+ continue;
1684
+ }
1685
+ };
1686
+ let mut config = config;
1687
+ if disabled_reason.is_none() && strict_config {
1688
+ validate_config_toml_strictly(
1689
+ config_file.as_path(),
1690
+ &contents,
1691
+ &config,
1692
+ dot_codex_abs.as_path(),
1693
+ )?;
1694
+ }
1695
+ let ignored_project_config_keys = sanitize_project_config(
1696
+ &mut config,
1697
+ trust_context.credential_broker,
1698
+ &trust_context.credential_broker_provider_env_keys,
1699
+ );
1700
+ if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
1701
+ startup_warnings.push(project_ignored_config_keys_warning(
1702
+ &dot_codex_abs,
1703
+ &ignored_project_config_keys,
1704
+ ));
1705
+ }
1706
+ layers.push(DiscoveredProjectLayer {
1707
+ dot_codex_folder: dot_codex_abs,
1708
+ config,
1709
+ disabled_reason,
1710
+ hooks_config_folder_override,
1711
+ load_root_checkout_hooks: true,
1712
+ });
1713
+ }
1714
+ Err(err) => {
1715
+ if err.kind() == io::ErrorKind::NotFound {
1716
+ // If there is no config.toml file, record an empty entry
1717
+ // for this project layer, as this may still have subfolders
1718
+ // that are significant in the overall ConfigLayerStack.
1719
+ layers.push(DiscoveredProjectLayer {
1720
+ dot_codex_folder: dot_codex_abs,
1721
+ config: TomlValue::Table(toml::map::Map::new()),
1722
+ disabled_reason,
1723
+ hooks_config_folder_override,
1724
+ load_root_checkout_hooks: true,
1725
+ });
1726
+ } else {
1727
+ let config_file_display = config_file.as_path().display();
1728
+ return Err(io::Error::new(
1729
+ err.kind(),
1730
+ format!("Failed to read project config file {config_file_display}: {err}"),
1731
+ ));
1732
+ }
1733
+ }
1734
+ }
1735
+ }
1736
+
1737
+ Ok(DiscoveredProjectLayers {
1738
+ layers,
1739
+ startup_warnings,
1740
+ })
1741
+ }
1742
+
1743
+ /// For linked worktrees, preserve ordinary worktree-local project config while
1744
+ /// replacing only hook declarations with the matching root-checkout layer.
1745
+ async fn merge_root_checkout_project_hooks(
1746
+ fs: &dyn ExecutorFileSystem,
1747
+ mut config: TomlValue,
1748
+ hooks_config_folder_override: Option<&AbsolutePathBuf>,
1749
+ is_trusted: bool,
1750
+ ) -> io::Result<TomlValue> {
1751
+ let Some(hooks_config_folder) = hooks_config_folder_override else {
1752
+ return Ok(config);
1753
+ };
1754
+ let root_config =
1755
+ load_root_checkout_project_config(fs, hooks_config_folder, is_trusted).await?;
1756
+ let root_config =
1757
+ resolve_relative_paths_in_config_toml(root_config, hooks_config_folder.as_path())?;
1758
+
1759
+ let Some(config_table) = config.as_table_mut() else {
1760
+ return Ok(config);
1761
+ };
1762
+ config_table.remove("hooks");
1763
+ if let Some(hooks) = root_config.get("hooks") {
1764
+ config_table.insert("hooks".to_string(), hooks.clone());
1765
+ }
1766
+ Ok(config)
1767
+ }
1768
+
1769
+ async fn load_root_checkout_project_config(
1770
+ fs: &dyn ExecutorFileSystem,
1771
+ hooks_config_folder: &AbsolutePathBuf,
1772
+ is_trusted: bool,
1773
+ ) -> io::Result<TomlValue> {
1774
+ let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
1775
+ let hooks_config_file_uri = PathUri::from_abs_path(&hooks_config_file);
1776
+ Ok(
1777
+ match fs
1778
+ .read_file_text(
1779
+ &hooks_config_file_uri,
1780
+ Default::default(),
1781
+ /*sandbox*/ None,
1782
+ )
1783
+ .await
1784
+ {
1785
+ Ok(contents) => {
1786
+ let parsed: TomlValue = match toml::from_str(&contents) {
1787
+ Ok(parsed) => parsed,
1788
+ Err(err) => {
1789
+ if is_trusted {
1790
+ return Err(io::Error::new(
1791
+ io::ErrorKind::InvalidData,
1792
+ format!(
1793
+ "Error parsing project hooks config file {}: {err}",
1794
+ hooks_config_file.as_path().display()
1795
+ ),
1796
+ ));
1797
+ }
1798
+ TomlValue::Table(toml::map::Map::new())
1799
+ }
1800
+ };
1801
+ parsed
1802
+ }
1803
+ Err(err) if err.kind() == io::ErrorKind::NotFound => {
1804
+ TomlValue::Table(toml::map::Map::new())
1805
+ }
1806
+ Err(err) => {
1807
+ return Err(io::Error::new(
1808
+ err.kind(),
1809
+ format!(
1810
+ "Failed to read project hooks config file {}: {err}",
1811
+ hooks_config_file.as_path().display()
1812
+ ),
1813
+ ));
1814
+ }
1815
+ },
1816
+ )
1817
+ }
1818
+ /// The legacy mechanism for specifying admin-enforced configuration is to read
1819
+ /// from a file like `/etc/codex/managed_config.toml` that has the same
1820
+ /// structure as `config.toml` where fields like `approval_policy` can specify
1821
+ /// exactly one value rather than a list of allowed values.
1822
+ ///
1823
+ /// If present, re-interpret `managed_config.toml` as a `requirements.toml`
1824
+ /// where each specified field is treated as a constraint. Most fields allow
1825
+ /// only the specified value. `approvals_reviewer = "auto_review"` also allows
1826
+ /// `user` so people can opt out of the auto-reviewer.
1827
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq)]
1828
+ struct LegacyManagedConfigToml {
1829
+ approval_policy: Option<AskForApproval>,
1830
+ approvals_reviewer: Option<ApprovalsReviewer>,
1831
+ sandbox_mode: Option<SandboxMode>,
1832
+ }
1833
+
1834
+ // Cannot name this `mod tests` because of tests.rs in this folder.
1835
+ #[cfg(test)]
1836
+ mod unit_tests {
1837
+ use super::*;
1838
+ #[cfg(windows)]
1839
+ use std::path::Path;
1840
+ use tempfile::tempdir;
1841
+
1842
+ #[test]
1843
+ fn ensure_resolve_relative_paths_in_config_toml_preserves_all_fields() -> anyhow::Result<()> {
1844
+ let tmp = tempdir()?;
1845
+ let base_dir = tmp.path();
1846
+ let contents = r#"
1847
+ # This is a field recognized by config.toml that is an AbsolutePathBuf in
1848
+ # the ConfigToml struct.
1849
+ model_instructions_file = "./some_file.md"
1850
+
1851
+ # This is a field recognized by config.toml.
1852
+ model = "gpt-1000"
1853
+
1854
+ # This is a field not recognized by config.toml.
1855
+ foo = "xyzzy"
1856
+ "#;
1857
+ let user_config: TomlValue = toml::from_str(contents)?;
1858
+
1859
+ let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?;
1860
+ let mut expected_toml_value = toml::map::Map::new();
1861
+ expected_toml_value.insert(
1862
+ "model_instructions_file".to_string(),
1863
+ TomlValue::String(
1864
+ AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)
1865
+ .as_path()
1866
+ .to_string_lossy()
1867
+ .to_string(),
1868
+ ),
1869
+ );
1870
+ expected_toml_value.insert(
1871
+ "model".to_string(),
1872
+ TomlValue::String("gpt-1000".to_string()),
1873
+ );
1874
+ expected_toml_value.insert("foo".to_string(), TomlValue::String("xyzzy".to_string()));
1875
+ assert_eq!(normalized_toml_value, TomlValue::Table(expected_toml_value));
1876
+ Ok(())
1877
+ }
1878
+
1879
+ #[test]
1880
+ fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> {
1881
+ let legacy = LegacyManagedConfigToml {
1882
+ approval_policy: None,
1883
+ approvals_reviewer: None,
1884
+ sandbox_mode: Some(SandboxMode::WorkspaceWrite),
1885
+ };
1886
+
1887
+ assert_eq!(
1888
+ legacy_requirements_to_toml_value(legacy)?,
1889
+ TomlValue::Table(toml::map::Map::from_iter([(
1890
+ "allowed_sandbox_modes".to_string(),
1891
+ TomlValue::Array(vec![
1892
+ TomlValue::String("read-only".to_string()),
1893
+ TomlValue::String("workspace-write".to_string()),
1894
+ ]),
1895
+ )]))
1896
+ );
1897
+ Ok(())
1898
+ }
1899
+
1900
+ #[test]
1901
+ fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() -> io::Result<()> {
1902
+ let legacy = LegacyManagedConfigToml {
1903
+ approval_policy: None,
1904
+ approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
1905
+ sandbox_mode: None,
1906
+ };
1907
+
1908
+ assert_eq!(
1909
+ legacy_requirements_to_toml_value(legacy)?,
1910
+ TomlValue::Table(toml::map::Map::from_iter([(
1911
+ "allowed_approvals_reviewers".to_string(),
1912
+ TomlValue::Array(vec![
1913
+ TomlValue::String("auto_review".to_string()),
1914
+ TomlValue::String("user".to_string()),
1915
+ ]),
1916
+ )]))
1917
+ );
1918
+ Ok(())
1919
+ }
1920
+
1921
+ #[test]
1922
+ fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() -> io::Result<()> {
1923
+ let legacy = LegacyManagedConfigToml {
1924
+ approval_policy: None,
1925
+ approvals_reviewer: Some(ApprovalsReviewer::User),
1926
+ sandbox_mode: None,
1927
+ };
1928
+
1929
+ assert_eq!(
1930
+ legacy_requirements_to_toml_value(legacy)?,
1931
+ TomlValue::Table(toml::map::Map::from_iter([(
1932
+ "allowed_approvals_reviewers".to_string(),
1933
+ TomlValue::Array(vec![TomlValue::String("user".to_string())]),
1934
+ )]))
1935
+ );
1936
+ Ok(())
1937
+ }
1938
+
1939
+ #[cfg(windows)]
1940
+ #[test]
1941
+ fn windows_system_requirements_toml_file_uses_expected_suffix() {
1942
+ let expected = windows_program_data_dir_from_known_folder()
1943
+ .unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS))
1944
+ .join("OpenAI")
1945
+ .join("Codex")
1946
+ .join("requirements.toml");
1947
+ assert_eq!(
1948
+ windows_system_requirements_toml_file()
1949
+ .expect("requirements.toml path")
1950
+ .as_path(),
1951
+ expected.as_path()
1952
+ );
1953
+ assert!(
1954
+ windows_system_requirements_toml_file()
1955
+ .expect("requirements.toml path")
1956
+ .as_path()
1957
+ .ends_with(Path::new("OpenAI").join("Codex").join("requirements.toml"))
1958
+ );
1959
+ }
1960
+
1961
+ #[cfg(windows)]
1962
+ #[test]
1963
+ fn windows_system_config_toml_file_uses_expected_suffix() {
1964
+ let expected = windows_program_data_dir_from_known_folder()
1965
+ .unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS))
1966
+ .join("OpenAI")
1967
+ .join("Codex")
1968
+ .join("config.toml");
1969
+ assert_eq!(
1970
+ windows_system_config_toml_file()
1971
+ .expect("config.toml path")
1972
+ .as_path(),
1973
+ expected.as_path()
1974
+ );
1975
+ assert!(
1976
+ windows_system_config_toml_file()
1977
+ .expect("config.toml path")
1978
+ .as_path()
1979
+ .ends_with(Path::new("OpenAI").join("Codex").join("config.toml"))
1980
+ );
1981
+ }
1982
+ }