Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- codex-rs/cloud-config/src/backend.rs +136 -0
- codex-rs/cloud-config/src/bundle_loader.rs +132 -0
- codex-rs/cloud-config/src/cache.rs +253 -0
- codex-rs/cloud-config/src/cache_tests.rs +206 -0
- codex-rs/cloud-config/src/lib.rs +15 -0
- codex-rs/cloud-config/src/metrics.rs +95 -0
- codex-rs/cloud-config/src/service.rs +538 -0
- codex-rs/cloud-config/src/service_tests.rs +1658 -0
- codex-rs/cloud-config/src/validation.rs +48 -0
- codex-rs/cloud-config/src/validation_tests.rs +51 -0
- codex-rs/codex-backend-openapi-models/src/lib.rs +6 -0
- codex-rs/codex-backend-openapi-models/src/models/additional_rate_limit_details.rs +38 -0
- codex-rs/codex-backend-openapi-models/src/models/analytics.rs +230 -0
- codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs +42 -0
- codex-rs/codex-backend-openapi-models/src/models/config_bundle_response.rs +40 -0
- codex-rs/codex-backend-openapi-models/src/models/config_file_response.rs +40 -0
- codex-rs/codex-backend-openapi-models/src/models/credit_status_details.rs +52 -0
- codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs +40 -0
- codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs +33 -0
- codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs +40 -0
- codex-rs/codex-backend-openapi-models/src/models/delivered_toml_fragment.rs +28 -0
- codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs +40 -0
- codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs +77 -0
- codex-rs/codex-backend-openapi-models/src/models/mod.rs +68 -0
- codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs +30 -0
- codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs +46 -0
- codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +143 -0
- codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs +39 -0
- codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs +60 -0
- codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs +35 -0
- codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs +63 -0
- codex-rs/codex-backend-openapi-models/src/models/task_response.rs +62 -0
- codex-rs/config-schema/src/main.rs +22 -0
- codex-rs/config/src/application_requirements.rs +71 -0
- codex-rs/config/src/bedrock_runtime_tests.rs +48 -0
- codex-rs/config/src/browser_computer_use_requirements_tests.rs +45 -0
- codex-rs/config/src/browser_use.rs +26 -0
- codex-rs/config/src/cloud_config_bundle.rs +237 -0
- codex-rs/config/src/cloud_config_layers_tests.rs +226 -0
- codex-rs/config/src/config_layer_source.rs +109 -0
- codex-rs/config/src/hooks_tests.rs +363 -0
- codex-rs/config/src/host_name.rs +119 -0
- codex-rs/config/src/in_app_browser_requirements_tests.rs +144 -0
- codex-rs/config/src/loader/README.md +90 -0
- codex-rs/config/src/loader/layer_io.rs +233 -0
- codex-rs/config/src/loader/local.rs +398 -0
- codex-rs/config/src/loader/macos.rs +230 -0
- codex-rs/config/src/loader/managed_project_discovery_tests.rs +348 -0
- codex-rs/config/src/loader/managed_requirements.rs +121 -0
- 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 |
+
}
|