mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## Why Regular chats start MCP servers eagerly even when cached tool definitions are available and the servers go unused. Allow those chats to use the cached catalog and defer eligible stdio servers until first use. ## What changed - Add the disabled-by-default `mcp_lazy_stdio_startup` feature and expose it through experimental feature configuration. - Defer cached stdio servers in regular chats when enabled, while keeping servers that require a live startup connection eager. Required servers with `startup_readiness = "catalog"` may defer; explicit reconnects remain eager. - Announce the full eager startup batch before terminal statuses and exclude dormant servers from the initial startup summary. Update TUI tracking so deferred servers can report startup independently in later rounds. ## Testing Add scenario coverage for a cached server staying dormant during unrelated work, starting on first use, and refreshing tool definitions and visibility. Cover required live startup, eager event ordering, experimental feature configuration, and TUI status handling. GitOrigin-RevId: 74075fcd4f26c8c79b5901b6a8cb03fdb9e35a7f
1359 lines
54 KiB
Rust
1359 lines
54 KiB
Rust
use codex_app_server_protocol::ApplicationStorageRequirements;
|
|
use std::sync::Arc;
|
|
|
|
use crate::config_manager::ConfigManager;
|
|
use crate::config_manager_service::ConfigManagerError;
|
|
use crate::error_code::internal_error;
|
|
use crate::error_code::invalid_request;
|
|
use crate::model_catalog::ModelCatalog;
|
|
use crate::otel_config::map_otel_requirements_to_api;
|
|
use crate::outgoing_message::ConnectionRequestId;
|
|
use crate::outgoing_message::OutgoingMessageSender;
|
|
use codex_analytics::AnalyticsEventsClient;
|
|
use codex_app_server_protocol::AllowDenyRequirement;
|
|
use codex_app_server_protocol::AutoReviewRequirements;
|
|
use codex_app_server_protocol::BrowserUseAccessApprovalLifetime;
|
|
use codex_app_server_protocol::BrowserUseOriginPolicy;
|
|
use codex_app_server_protocol::BrowserUseRequirements;
|
|
use codex_app_server_protocol::CliAuthCredentialsStoreMode;
|
|
use codex_app_server_protocol::ClientResponsePayload;
|
|
use codex_app_server_protocol::ComputerUseMacosRequirements;
|
|
use codex_app_server_protocol::ComputerUseRequirements;
|
|
use codex_app_server_protocol::ComputerUseWindowsExeRequirement;
|
|
use codex_app_server_protocol::ComputerUseWindowsRequirements;
|
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
|
use codex_app_server_protocol::ConfigReadParams;
|
|
use codex_app_server_protocol::ConfigReadResponse;
|
|
use codex_app_server_protocol::ConfigRequirements;
|
|
use codex_app_server_protocol::ConfigRequirementsReadResponse;
|
|
use codex_app_server_protocol::ConfigValueWriteParams;
|
|
use codex_app_server_protocol::ConfigWriteErrorCode;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::ConfiguredHookHandler;
|
|
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
|
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
|
use codex_app_server_protocol::FeedbackRequirements;
|
|
use codex_app_server_protocol::InAppBrowserRequirements;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::ManagedHooksRequirements;
|
|
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
|
|
use codex_app_server_protocol::ModelsRequirements;
|
|
use codex_app_server_protocol::NetworkDomainPermission;
|
|
use codex_app_server_protocol::NetworkRequirements;
|
|
use codex_app_server_protocol::NetworkUnixSocketPermission;
|
|
use codex_app_server_protocol::NewThreadModelDefaults;
|
|
use codex_app_server_protocol::SandboxMode;
|
|
use codex_app_server_protocol::WindowsSandboxImplementation;
|
|
use codex_config::ConfigRequirementsToml;
|
|
use codex_config::HookEventsToml;
|
|
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
|
|
use codex_config::ManagedHooksRequirementsToml;
|
|
use codex_config::MatcherGroup as CoreMatcherGroup;
|
|
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
|
|
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
|
|
use codex_core::ThreadManager;
|
|
use codex_features::Feature;
|
|
use codex_features::canonical_feature_for_key;
|
|
use codex_features::feature_for_key;
|
|
use codex_model_provider::create_model_provider;
|
|
use codex_plugin::PluginId;
|
|
use codex_protocol::config_types::ForcedLoginMethod;
|
|
use codex_protocol::config_types::WebSearchMode;
|
|
use serde_json::json;
|
|
use std::path::PathBuf;
|
|
|
|
const BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE: &str =
|
|
"background_paginated_rollout_migration";
|
|
|
|
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
|
"api_key_cyber_access_programs",
|
|
"api_key_model_discovery",
|
|
"auth_elicitation",
|
|
BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE,
|
|
"codex_apps_mcp_2026_07_28",
|
|
"mcp_2026_07_28",
|
|
"mcp_connection_sharing",
|
|
"mcp_lazy_stdio_startup",
|
|
"memories",
|
|
"mentions_v2",
|
|
"remote_control",
|
|
"remote_plugin",
|
|
"tool_suggest",
|
|
"windows_sandbox_service",
|
|
];
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ConfigRequestProcessor {
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
config_manager: ConfigManager,
|
|
thread_manager: Arc<ThreadManager>,
|
|
model_catalog: Arc<ModelCatalog>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
}
|
|
|
|
impl ConfigRequestProcessor {
|
|
pub(crate) fn new(
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
config_manager: ConfigManager,
|
|
thread_manager: Arc<ThreadManager>,
|
|
model_catalog: Arc<ModelCatalog>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
) -> Self {
|
|
Self {
|
|
outgoing,
|
|
config_manager,
|
|
thread_manager,
|
|
model_catalog,
|
|
analytics_events_client,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn read(
|
|
&self,
|
|
params: ConfigReadParams,
|
|
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
|
|
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
|
|
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
|
|
let config = self.load_latest_config(fallback_cwd).await?;
|
|
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
|
|
let Some(feature) = feature_for_key(feature_key) else {
|
|
continue;
|
|
};
|
|
let features = response
|
|
.config
|
|
.additional
|
|
.entry("features".to_string())
|
|
.or_insert_with(|| json!({}));
|
|
if !features.is_object() {
|
|
*features = json!({});
|
|
}
|
|
if let Some(features) = features.as_object_mut() {
|
|
features.insert(
|
|
(*feature_key).to_string(),
|
|
json!(config.features.enabled(feature)),
|
|
);
|
|
}
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
pub(crate) async fn config_requirements_read(
|
|
&self,
|
|
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
|
|
let requirements = self
|
|
.config_manager
|
|
.read_requirements()
|
|
.await
|
|
.map_err(map_error)?;
|
|
let requirements = map_requirements_to_api(
|
|
requirements,
|
|
self.thread_manager.auth_manager().allowed_login_methods(),
|
|
);
|
|
|
|
Ok(ConfigRequirementsReadResponse {
|
|
supports_independent_speed_modes: Some(true),
|
|
requirements,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn value_write(
|
|
&self,
|
|
params: ConfigValueWriteParams,
|
|
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
|
|
self.handle_config_mutation_result(self.write_value(params).await)
|
|
.await
|
|
.map(ClientResponsePayload::ConfigValueWrite)
|
|
}
|
|
|
|
pub(crate) async fn batch_write(
|
|
&self,
|
|
params: ConfigBatchWriteParams,
|
|
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
|
|
let session_defaults_only = !params.edits.is_empty()
|
|
&& params.edits.iter().all(|edit| {
|
|
matches!(
|
|
edit.key_path.as_str(),
|
|
"model"
|
|
| "model_reasoning_effort"
|
|
| "plan_mode_reasoning_effort"
|
|
| "service_tier"
|
|
| "personality"
|
|
)
|
|
});
|
|
let should_reload = params.reload_user_config;
|
|
let response = self.batch_write_inner(params).await?;
|
|
if !session_defaults_only {
|
|
self.handle_config_mutation().await;
|
|
if should_reload {
|
|
reload_user_config(&self.config_manager, &self.thread_manager).await;
|
|
}
|
|
}
|
|
Ok(ClientResponsePayload::ConfigBatchWrite(response))
|
|
}
|
|
|
|
pub(crate) async fn experimental_feature_enablement_set(
|
|
&self,
|
|
request_id: ConnectionRequestId,
|
|
params: ExperimentalFeatureEnablementSetParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
let response = self
|
|
.handle_config_mutation_result(self.set_experimental_feature_enablement(params).await)
|
|
.await?;
|
|
if !response.enablement.is_empty() {
|
|
reload_user_config(&self.config_manager, &self.thread_manager).await;
|
|
}
|
|
self.outgoing
|
|
.send_response_as(
|
|
request_id,
|
|
ClientResponsePayload::ExperimentalFeatureEnablementSet(response),
|
|
)
|
|
.await;
|
|
Ok(None)
|
|
}
|
|
|
|
pub(crate) async fn model_provider_list(
|
|
&self,
|
|
params: codex_app_server_protocol::ModelProviderListParams,
|
|
) -> Result<codex_app_server_protocol::ModelProviderListResponse, JSONRPCErrorError> {
|
|
self.config_manager
|
|
.model_provider_list(params)
|
|
.await
|
|
.map_err(map_error)
|
|
}
|
|
|
|
pub(crate) async fn model_provider_config_write(
|
|
&self,
|
|
params: codex_app_server_protocol::ModelProviderConfigWriteParams,
|
|
) -> Result<codex_app_server_protocol::ModelProviderConfigWriteResponse, JSONRPCErrorError>
|
|
{
|
|
let result = self
|
|
.config_manager
|
|
.model_provider_config_write(params)
|
|
.await
|
|
.map_err(map_error);
|
|
let response = self.handle_config_mutation_result(result).await?;
|
|
self.model_catalog
|
|
.invalidate_if_current_provider(&response.provider.id)
|
|
.await;
|
|
Ok(response)
|
|
}
|
|
|
|
pub(crate) async fn model_provider_capabilities_read(
|
|
&self,
|
|
) -> Result<ModelProviderCapabilitiesReadResponse, JSONRPCErrorError> {
|
|
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
|
let provider = create_model_provider(config.model_provider, /*auth_manager*/ None);
|
|
let capabilities = provider.capabilities();
|
|
Ok(ModelProviderCapabilitiesReadResponse {
|
|
image_generation: capabilities.image_generation,
|
|
web_search: capabilities.web_search,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn handle_config_mutation(&self) {
|
|
self.thread_manager.plugins_manager().clear_cache();
|
|
self.thread_manager.skills_service().clear_cache();
|
|
}
|
|
|
|
async fn handle_config_mutation_result<T>(
|
|
&self,
|
|
result: std::result::Result<T, JSONRPCErrorError>,
|
|
) -> Result<T, JSONRPCErrorError> {
|
|
let response = result?;
|
|
self.handle_config_mutation().await;
|
|
Ok(response)
|
|
}
|
|
|
|
async fn load_latest_config(
|
|
&self,
|
|
fallback_cwd: Option<PathBuf>,
|
|
) -> Result<codex_core::config::Config, JSONRPCErrorError> {
|
|
self.config_manager
|
|
.load_latest_config(fallback_cwd)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!(
|
|
"failed to resolve feature override precedence: {err}"
|
|
))
|
|
})
|
|
}
|
|
|
|
async fn write_value(
|
|
&self,
|
|
params: ConfigValueWriteParams,
|
|
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
|
let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates(
|
|
[(¶ms.key_path, ¶ms.value)].into_iter(),
|
|
);
|
|
let response = self
|
|
.config_manager
|
|
.write_value(params)
|
|
.await
|
|
.map_err(map_error)?;
|
|
self.emit_plugin_toggle_events(pending_changes).await;
|
|
Ok(response)
|
|
}
|
|
|
|
async fn batch_write_inner(
|
|
&self,
|
|
params: ConfigBatchWriteParams,
|
|
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
|
let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates(
|
|
params
|
|
.edits
|
|
.iter()
|
|
.map(|edit| (&edit.key_path, &edit.value)),
|
|
);
|
|
let response = self
|
|
.config_manager
|
|
.batch_write(params)
|
|
.await
|
|
.map_err(map_error)?;
|
|
self.emit_plugin_toggle_events(pending_changes).await;
|
|
Ok(response)
|
|
}
|
|
|
|
async fn set_experimental_feature_enablement(
|
|
&self,
|
|
params: ExperimentalFeatureEnablementSetParams,
|
|
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
|
|
let ExperimentalFeatureEnablementSetParams { mut enablement } = params;
|
|
let mut invalid_keys = Vec::new();
|
|
enablement.retain(|key, _| {
|
|
let valid = canonical_feature_for_key(key).is_some()
|
|
&& SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str());
|
|
if !valid {
|
|
invalid_keys.push(key.clone());
|
|
}
|
|
valid
|
|
});
|
|
if !invalid_keys.is_empty() {
|
|
let invalid_keys = invalid_keys.join(", ");
|
|
tracing::warn!("ignoring invalid experimental feature enablement keys: {invalid_keys}");
|
|
}
|
|
|
|
if enablement.is_empty() {
|
|
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
|
|
}
|
|
|
|
// Most runtime features are read later from config. Background migration is a one-shot
|
|
// process-scoped task, so start it when runtime enablement first changes it to on.
|
|
let feature = Feature::BackgroundPaginatedRolloutMigration;
|
|
let should_start_background_rollout_migration = enablement
|
|
.get(BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE)
|
|
.is_some_and(|enabled| *enabled)
|
|
&& !self
|
|
.load_latest_config(/*fallback_cwd*/ None)
|
|
.await?
|
|
.features
|
|
.enabled(feature);
|
|
|
|
self.config_manager
|
|
.extend_runtime_feature_enablement(
|
|
enablement
|
|
.iter()
|
|
.map(|(name, enabled)| (name.clone(), *enabled)),
|
|
)
|
|
.map_err(|_| internal_error("failed to update feature enablement"))?;
|
|
|
|
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
|
self.thread_manager.set_api_key_model_discovery_enabled(
|
|
config.features.enabled(Feature::ApiKeyModelDiscovery),
|
|
);
|
|
if should_start_background_rollout_migration && config.features.enabled(feature) {
|
|
self.thread_manager.start_background_rollout_migration();
|
|
}
|
|
|
|
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
|
|
}
|
|
|
|
async fn emit_plugin_toggle_events(
|
|
&self,
|
|
pending_changes: std::collections::BTreeMap<String, bool>,
|
|
) {
|
|
let plugins_manager = self.thread_manager.plugins_manager();
|
|
for (plugin_id, enabled) in pending_changes {
|
|
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
|
|
continue;
|
|
};
|
|
let metadata = plugins_manager
|
|
.telemetry_metadata_for_installed_plugin(&plugin_id)
|
|
.await;
|
|
if enabled {
|
|
self.analytics_events_client.track_plugin_enabled(metadata);
|
|
} else {
|
|
self.analytics_events_client.track_plugin_disabled(metadata);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn reload_user_config(
|
|
config_manager: &ConfigManager,
|
|
thread_manager: &ThreadManager,
|
|
) {
|
|
// Reload owns several config snapshots; do not inline that future into dispatch.
|
|
Box::pin(reload_user_config_inner(config_manager, thread_manager)).await;
|
|
}
|
|
|
|
async fn reload_user_config_inner(config_manager: &ConfigManager, thread_manager: &ThreadManager) {
|
|
config_manager.revoke_mcp_ema_startups();
|
|
if let Err(err) = config_manager.load_config_layers(/*cwd*/ None).await {
|
|
tracing::warn!("failed to rebuild user config for runtime refresh: {err}");
|
|
for thread_id in thread_manager.list_thread_ids().await {
|
|
if let Ok(thread) = thread_manager.get_thread(thread_id).await {
|
|
thread.disable_mcp_enterprise_auth().await;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
let thread_ids = thread_manager.list_thread_ids().await;
|
|
for thread_id in thread_ids {
|
|
let Ok(thread) = thread_manager.get_thread(thread_id).await else {
|
|
continue;
|
|
};
|
|
for attempt in 0..4 {
|
|
let current_config = thread.config().await;
|
|
let next_config = match config_manager
|
|
.load_latest_config_with_session_layers(
|
|
¤t_config.config_layer_stack,
|
|
¤t_config.cwd,
|
|
)
|
|
.await
|
|
{
|
|
Ok(config) => config,
|
|
Err(err) => {
|
|
tracing::warn!(%thread_id, %err, "failed to reload thread configuration");
|
|
thread.disable_mcp_enterprise_auth().await;
|
|
break;
|
|
}
|
|
};
|
|
let current_requirements = current_config.config_layer_stack.requirements();
|
|
let next_requirements = next_config.config_layer_stack.requirements();
|
|
let promote_mcp = current_requirements.mcp_servers != next_requirements.mcp_servers
|
|
|| current_requirements.plugins != next_requirements.plugins
|
|
|| current_requirements.feature_requirements
|
|
!= next_requirements.feature_requirements;
|
|
let outcome = if promote_mcp {
|
|
// Keep the loaded MCP map and its requirements under one owner,
|
|
// then reload the user update from that owner.
|
|
Box::pin(thread.refresh_mcp_config(current_config, next_config)).await
|
|
} else {
|
|
// Keep runtime refresh state off the request dispatcher's stack.
|
|
Box::pin(thread.refresh_runtime_config(current_config, next_config)).await
|
|
};
|
|
match outcome {
|
|
codex_core::ConfigRefreshOutcome::Published => {
|
|
if !promote_mcp {
|
|
break;
|
|
}
|
|
}
|
|
codex_core::ConfigRefreshOutcome::Stale => {}
|
|
codex_core::ConfigRefreshOutcome::Rejected => {
|
|
tracing::warn!(%thread_id, "stopped user configuration reload after rejected refresh");
|
|
break;
|
|
}
|
|
}
|
|
if attempt == 3 {
|
|
thread.disable_mcp_enterprise_auth().await;
|
|
tracing::warn!(%thread_id, "configuration kept changing during user reload; enterprise MCP disabled");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn map_requirements_to_api(
|
|
requirements: Option<ConfigRequirementsToml>,
|
|
allowed_login_methods: Vec<ForcedLoginMethod>,
|
|
) -> Option<ConfigRequirements> {
|
|
let requirements = match requirements {
|
|
Some(requirements) => requirements,
|
|
None if allowed_login_methods == [ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt] => {
|
|
return None;
|
|
}
|
|
None => ConfigRequirementsToml::default(),
|
|
};
|
|
let network = requirements
|
|
.permissions
|
|
.as_ref()
|
|
.and_then(|permissions| permissions.network.as_deref())
|
|
.cloned()
|
|
.or_else(|| requirements.network.clone());
|
|
|
|
Some(ConfigRequirements {
|
|
model_provider: requirements.model_provider,
|
|
model_providers: requirements.model_providers.map(|providers| {
|
|
providers
|
|
.into_iter()
|
|
.map(|(id, provider)| (id, serde_json::json!(provider)))
|
|
.collect()
|
|
}),
|
|
allowed_login_methods: Some(allowed_login_methods),
|
|
application: requirements.application.map(|application| {
|
|
codex_app_server_protocol::ApplicationRequirements {
|
|
storage: application
|
|
.storage
|
|
.map(|storage| ApplicationStorageRequirements {
|
|
default_project_parent: storage.default_project_parent.map(Into::into),
|
|
projectless_workspace_root: storage
|
|
.projectless_workspace_root
|
|
.map(Into::into),
|
|
}),
|
|
network: application.network.map(|network| {
|
|
codex_app_server_protocol::ApplicationNetworkRequirements {
|
|
enabled: network.enabled,
|
|
domains: network
|
|
.domains
|
|
.into_iter()
|
|
.map(|(domain, permission)| {
|
|
(domain, map_network_domain_permission_to_api(permission))
|
|
})
|
|
.collect(),
|
|
}
|
|
}),
|
|
}
|
|
}),
|
|
cli_auth_credentials_store: requirements.cli_auth_credentials_store.map(
|
|
|mode| match mode {
|
|
codex_config::types::AuthCredentialsStoreMode::File => {
|
|
CliAuthCredentialsStoreMode::File
|
|
}
|
|
codex_config::types::AuthCredentialsStoreMode::Keyring => {
|
|
CliAuthCredentialsStoreMode::Keyring
|
|
}
|
|
codex_config::types::AuthCredentialsStoreMode::Auto => {
|
|
CliAuthCredentialsStoreMode::Auto
|
|
}
|
|
codex_config::types::AuthCredentialsStoreMode::Ephemeral => {
|
|
CliAuthCredentialsStoreMode::Ephemeral
|
|
}
|
|
},
|
|
),
|
|
chatgpt_base_url: requirements.chatgpt_base_url,
|
|
additional_developer_instructions: requirements.additional_developer_instructions,
|
|
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
|
|
policies
|
|
.into_iter()
|
|
.map(codex_app_server_protocol::AskForApproval::from)
|
|
.collect()
|
|
}),
|
|
allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| {
|
|
reviewers
|
|
.into_iter()
|
|
.map(codex_app_server_protocol::ApprovalsReviewer::from)
|
|
.collect()
|
|
}),
|
|
allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| {
|
|
modes
|
|
.into_iter()
|
|
.filter_map(map_sandbox_mode_requirement_to_api)
|
|
.collect()
|
|
}),
|
|
allowed_windows_sandbox_implementations: requirements.windows.and_then(|windows| {
|
|
windows
|
|
.allowed_sandbox_implementations
|
|
.map(|implementations| {
|
|
implementations
|
|
.into_iter()
|
|
.map(|implementation| match implementation {
|
|
codex_config::WindowsSandboxImplementationToml::Elevated => {
|
|
WindowsSandboxImplementation::Elevated
|
|
}
|
|
codex_config::WindowsSandboxImplementationToml::Unelevated => {
|
|
WindowsSandboxImplementation::Unelevated
|
|
}
|
|
})
|
|
.collect()
|
|
})
|
|
}),
|
|
allowed_permission_profiles: requirements.allowed_permission_profiles,
|
|
default_permissions: requirements.default_permissions,
|
|
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
|
|
let mut normalized = modes
|
|
.into_iter()
|
|
.map(Into::into)
|
|
.collect::<Vec<WebSearchMode>>();
|
|
if !normalized.contains(&WebSearchMode::Disabled) {
|
|
normalized.push(WebSearchMode::Disabled);
|
|
}
|
|
normalized
|
|
}),
|
|
allow_managed_hooks_only: requirements.allow_managed_hooks_only,
|
|
allow_browser_and_computer_use: requirements.allow_browser_and_computer_use,
|
|
allow_appshots: requirements.allow_appshots,
|
|
allow_remote_control: requirements.allow_remote_control,
|
|
computer_use: requirements
|
|
.computer_use
|
|
.map(map_computer_use_requirements_to_api),
|
|
browser_use: requirements
|
|
.browser_use
|
|
.map(map_browser_use_requirements_to_api),
|
|
in_app_browser: requirements.in_app_browser.map(|in_app_browser| {
|
|
InAppBrowserRequirements {
|
|
allow_external_browser_settings_import: in_app_browser
|
|
.allow_external_browser_settings_import,
|
|
}
|
|
}),
|
|
feature_requirements: requirements
|
|
.feature_requirements
|
|
.map(|requirements| requirements.entries),
|
|
hooks: requirements
|
|
.hooks
|
|
.map(map_hooks_requirements_to_api)
|
|
.map(Box::new),
|
|
enforce_residency: requirements
|
|
.enforce_residency
|
|
.map(map_residency_requirement_to_api),
|
|
network: network.map(map_network_requirements_to_api),
|
|
auto_review: requirements
|
|
.auto_review
|
|
.map(|auto_review| AutoReviewRequirements {
|
|
required_on_models: auto_review.required_on_models,
|
|
ignore_rules: auto_review.ignore_rules,
|
|
}),
|
|
models: requirements.models.map(|models| ModelsRequirements {
|
|
new_thread: models.new_thread.map(|new_thread| NewThreadModelDefaults {
|
|
model: new_thread.model,
|
|
model_reasoning_effort: new_thread.model_reasoning_effort,
|
|
service_tier: new_thread.service_tier,
|
|
}),
|
|
}),
|
|
otel: requirements
|
|
.otel
|
|
.map(|otel| map_otel_requirements_to_api(*otel)),
|
|
sqlite_home: requirements.sqlite_home.map(Into::into),
|
|
log_dir: requirements.log_dir.map(Into::into),
|
|
model_catalog_json: requirements.model_catalog_json.map(Into::into),
|
|
check_for_update_on_startup: requirements.check_for_update_on_startup,
|
|
allow_login_shell: requirements.allow_login_shell,
|
|
feedback: requirements.feedback.map(|feedback| FeedbackRequirements {
|
|
enabled: feedback.enabled,
|
|
}),
|
|
})
|
|
}
|
|
|
|
fn map_computer_use_requirements_to_api(
|
|
computer_use: codex_config::ComputerUseRequirementsToml,
|
|
) -> ComputerUseRequirements {
|
|
ComputerUseRequirements {
|
|
allow_locked_computer_use: computer_use.allow_locked_computer_use,
|
|
allow_persistent_approval: computer_use.allow_persistent_approval,
|
|
default_app_access: computer_use
|
|
.default_app_access
|
|
.map(map_allow_deny_requirement_to_api),
|
|
macos: computer_use
|
|
.macos
|
|
.map(|macos| ComputerUseMacosRequirements {
|
|
bundle_ids: macos.bundle_ids.map(|bundle_ids| {
|
|
bundle_ids
|
|
.into_iter()
|
|
.map(|(bundle_id, requirement)| {
|
|
(bundle_id, map_allow_deny_requirement_to_api(requirement))
|
|
})
|
|
.collect()
|
|
}),
|
|
}),
|
|
windows: computer_use
|
|
.windows
|
|
.map(|windows| ComputerUseWindowsRequirements {
|
|
aumids: windows.aumids.map(|aumids| {
|
|
aumids
|
|
.into_iter()
|
|
.map(|(aumid, requirement)| {
|
|
(aumid, map_allow_deny_requirement_to_api(requirement))
|
|
})
|
|
.collect()
|
|
}),
|
|
exes: windows.exes.map(|exes| {
|
|
exes.into_iter()
|
|
.map(|exe| ComputerUseWindowsExeRequirement {
|
|
publisher_name: exe.publisher_name,
|
|
product_name: exe.product_name,
|
|
binary_name: exe.binary_name,
|
|
access: map_allow_deny_requirement_to_api(exe.access),
|
|
})
|
|
.collect()
|
|
}),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn map_browser_use_requirements_to_api(
|
|
browser_use: codex_config::BrowserUseRequirementsToml,
|
|
) -> BrowserUseRequirements {
|
|
BrowserUseRequirements {
|
|
extension: browser_use.extension.map(|extension| {
|
|
codex_app_server_protocol::BrowserUseExtensionRequirements {
|
|
request_headers: extension.request_headers.map(|headers| {
|
|
headers
|
|
.into_iter()
|
|
.map(|header| codex_app_server_protocol::RequestHeader {
|
|
name: header.name,
|
|
value: header.value,
|
|
})
|
|
.collect()
|
|
}),
|
|
}
|
|
}),
|
|
allow_webmcp: browser_use.allow_webmcp,
|
|
allow_history_access: browser_use.allow_history_access,
|
|
disable_auto_review: browser_use.disable_auto_review,
|
|
allow_global_persistent_approval: browser_use.allow_global_persistent_approval,
|
|
default_origin_policy: browser_use
|
|
.default_origin_policy
|
|
.map(map_browser_use_origin_policy_to_api),
|
|
origins: browser_use.origins.map(|origins| {
|
|
origins
|
|
.into_iter()
|
|
.map(|(pattern, policy)| (pattern, map_browser_use_origin_policy_to_api(policy)))
|
|
.collect()
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn map_browser_use_origin_policy_to_api(
|
|
policy: codex_config::BrowserUseOriginPolicyToml,
|
|
) -> BrowserUseOriginPolicy {
|
|
BrowserUseOriginPolicy {
|
|
access: policy.access.map(map_allow_deny_requirement_to_api),
|
|
downloads: policy.downloads.map(map_allow_deny_requirement_to_api),
|
|
uploads: policy.uploads.map(map_allow_deny_requirement_to_api),
|
|
full_cdp_access: policy
|
|
.full_cdp_access
|
|
.map(map_allow_deny_requirement_to_api),
|
|
auto_review: policy.auto_review.map(map_allow_deny_requirement_to_api),
|
|
persistent_approval: policy.persistent_approval,
|
|
access_approval_lifetime: policy
|
|
.access_approval_lifetime
|
|
.map(map_browser_use_access_approval_lifetime_to_api),
|
|
}
|
|
}
|
|
|
|
fn map_allow_deny_requirement_to_api(
|
|
requirement: codex_config::AllowDenyRequirementToml,
|
|
) -> AllowDenyRequirement {
|
|
match requirement {
|
|
codex_config::AllowDenyRequirementToml::Allow => AllowDenyRequirement::Allow,
|
|
codex_config::AllowDenyRequirementToml::Deny => AllowDenyRequirement::Deny,
|
|
}
|
|
}
|
|
|
|
fn map_browser_use_access_approval_lifetime_to_api(
|
|
lifetime: codex_config::BrowserUseAccessApprovalLifetimeToml,
|
|
) -> BrowserUseAccessApprovalLifetime {
|
|
match lifetime {
|
|
codex_config::BrowserUseAccessApprovalLifetimeToml::Turn => {
|
|
BrowserUseAccessApprovalLifetime::Turn
|
|
}
|
|
codex_config::BrowserUseAccessApprovalLifetimeToml::Thread => {
|
|
BrowserUseAccessApprovalLifetime::Thread
|
|
}
|
|
}
|
|
}
|
|
|
|
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
|
|
let ManagedHooksRequirementsToml {
|
|
managed_dir,
|
|
windows_managed_dir,
|
|
hooks,
|
|
} = hooks;
|
|
let HookEventsToml {
|
|
pre_model_request,
|
|
post_model_request,
|
|
pre_tool_use,
|
|
permission_request,
|
|
post_tool_use,
|
|
pre_compact,
|
|
post_compact,
|
|
session_start,
|
|
session_end,
|
|
user_prompt_submit,
|
|
subagent_start,
|
|
subagent_stop,
|
|
stop,
|
|
interrupt,
|
|
} = *hooks;
|
|
|
|
ManagedHooksRequirements {
|
|
managed_dir,
|
|
windows_managed_dir,
|
|
pre_model_request: map_hook_matcher_groups_to_api(pre_model_request),
|
|
post_model_request: map_hook_matcher_groups_to_api(post_model_request),
|
|
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
|
|
permission_request: map_hook_matcher_groups_to_api(permission_request),
|
|
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
|
|
pre_compact: map_hook_matcher_groups_to_api(pre_compact),
|
|
post_compact: map_hook_matcher_groups_to_api(post_compact),
|
|
session_start: map_hook_matcher_groups_to_api(session_start),
|
|
session_end: map_hook_matcher_groups_to_api(session_end),
|
|
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
|
|
subagent_start: map_hook_matcher_groups_to_api(subagent_start),
|
|
subagent_stop: map_hook_matcher_groups_to_api(subagent_stop),
|
|
stop: map_hook_matcher_groups_to_api(stop),
|
|
interrupt: map_hook_matcher_groups_to_api(interrupt),
|
|
}
|
|
}
|
|
|
|
fn map_hook_matcher_groups_to_api(
|
|
groups: Vec<CoreMatcherGroup>,
|
|
) -> Vec<ConfiguredHookMatcherGroup> {
|
|
groups
|
|
.into_iter()
|
|
.map(map_hook_matcher_group_to_api)
|
|
.collect()
|
|
}
|
|
|
|
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
|
|
ConfiguredHookMatcherGroup {
|
|
matcher: group.matcher,
|
|
hooks: group
|
|
.hooks
|
|
.into_iter()
|
|
.map(map_hook_handler_to_api)
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
|
|
match handler {
|
|
CoreHookHandlerConfig::Command {
|
|
command,
|
|
command_windows,
|
|
timeout_sec,
|
|
r#async,
|
|
status_message,
|
|
additional_context_limit,
|
|
} => ConfiguredHookHandler::Command {
|
|
command,
|
|
command_windows,
|
|
timeout_sec,
|
|
r#async,
|
|
status_message,
|
|
additional_context_limit,
|
|
},
|
|
CoreHookHandlerConfig::McpTool {
|
|
server,
|
|
tool,
|
|
input,
|
|
timeout_sec,
|
|
status_message,
|
|
} => ConfiguredHookHandler::McpTool {
|
|
server,
|
|
tool,
|
|
input,
|
|
timeout_sec,
|
|
status_message,
|
|
},
|
|
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
|
|
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
|
|
}
|
|
}
|
|
|
|
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
|
|
match mode {
|
|
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
|
|
CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite),
|
|
CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess),
|
|
CoreSandboxModeRequirement::ExternalSandbox => None,
|
|
}
|
|
}
|
|
|
|
fn map_residency_requirement_to_api(
|
|
residency: CoreResidencyRequirement,
|
|
) -> codex_app_server_protocol::ResidencyRequirement {
|
|
match residency {
|
|
CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us,
|
|
}
|
|
}
|
|
|
|
fn map_network_requirements_to_api(
|
|
network: codex_config::NetworkRequirementsToml,
|
|
) -> NetworkRequirements {
|
|
let allowed_domains = network
|
|
.domains
|
|
.as_ref()
|
|
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains);
|
|
let denied_domains = network
|
|
.domains
|
|
.as_ref()
|
|
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
|
|
let allow_unix_sockets = network
|
|
.unix_sockets
|
|
.as_ref()
|
|
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
|
|
.filter(|entries| !entries.is_empty());
|
|
|
|
NetworkRequirements {
|
|
enabled: network.enabled,
|
|
http_port: network.http_port,
|
|
socks_port: network.socks_port,
|
|
allow_upstream_proxy: network.allow_upstream_proxy,
|
|
dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy,
|
|
dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets,
|
|
domains: network.domains.map(|domains| {
|
|
domains
|
|
.entries
|
|
.into_iter()
|
|
.map(|(pattern, permission)| {
|
|
(pattern, map_network_domain_permission_to_api(permission))
|
|
})
|
|
.collect()
|
|
}),
|
|
review_domains: network.review_domains,
|
|
managed_allowed_domains_only: network.managed_allowed_domains_only,
|
|
allowed_domains,
|
|
denied_domains,
|
|
unix_sockets: network.unix_sockets.map(|unix_sockets| {
|
|
unix_sockets
|
|
.entries
|
|
.into_iter()
|
|
.map(|(path, permission)| {
|
|
(path, map_network_unix_socket_permission_to_api(permission))
|
|
})
|
|
.collect()
|
|
}),
|
|
allow_unix_sockets,
|
|
allow_local_binding: network.allow_local_binding,
|
|
}
|
|
}
|
|
|
|
fn map_network_domain_permission_to_api(
|
|
permission: codex_config::NetworkDomainPermissionToml,
|
|
) -> NetworkDomainPermission {
|
|
match permission {
|
|
codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow,
|
|
codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny,
|
|
}
|
|
}
|
|
|
|
fn map_network_unix_socket_permission_to_api(
|
|
permission: codex_config::NetworkUnixSocketPermissionToml,
|
|
) -> NetworkUnixSocketPermission {
|
|
match permission {
|
|
codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow,
|
|
codex_config::NetworkUnixSocketPermissionToml::Deny => NetworkUnixSocketPermission::Deny,
|
|
}
|
|
}
|
|
|
|
pub(super) fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
|
|
if let Some(code) = err.write_error_code() {
|
|
return config_write_error(code, err.to_string());
|
|
}
|
|
|
|
internal_error(err.to_string())
|
|
}
|
|
|
|
fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
|
|
let mut error = invalid_request(message);
|
|
error.data = Some(json!({
|
|
"config_write_error_code": code,
|
|
}));
|
|
error
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::map_requirements_to_api;
|
|
use super::reload_user_config;
|
|
use crate::config_manager::ConfigManager;
|
|
use codex_app_server_protocol::AllowDenyRequirement;
|
|
use codex_app_server_protocol::AutoReviewRequirements;
|
|
use codex_app_server_protocol::BrowserUseAccessApprovalLifetime;
|
|
use codex_app_server_protocol::BrowserUseOriginPolicy;
|
|
use codex_app_server_protocol::BrowserUseRequirements;
|
|
use codex_app_server_protocol::ComputerUseMacosRequirements;
|
|
use codex_app_server_protocol::ComputerUseRequirements;
|
|
use codex_app_server_protocol::ComputerUseWindowsExeRequirement;
|
|
use codex_app_server_protocol::ComputerUseWindowsRequirements;
|
|
use codex_app_server_protocol::FeedbackRequirements;
|
|
use codex_app_server_protocol::WindowsSandboxImplementation;
|
|
use codex_config::AllowDenyRequirementToml;
|
|
use codex_config::AutoReviewRequirementsToml;
|
|
use codex_config::BrowserUseAccessApprovalLifetimeToml;
|
|
use codex_config::BrowserUseOriginPolicyToml;
|
|
use codex_config::BrowserUseRequirementsToml;
|
|
use codex_config::CloudConfigBundleLoader;
|
|
use codex_config::ComputerUseMacosRequirementsToml;
|
|
use codex_config::ComputerUseRequirementsToml;
|
|
use codex_config::ComputerUseWindowsExeRequirementToml;
|
|
use codex_config::ComputerUseWindowsRequirementsToml;
|
|
use codex_config::ConfigRequirementsToml;
|
|
use codex_config::LoaderOverrides;
|
|
use codex_config::ModelsRequirementsToml;
|
|
use codex_config::NewThreadModelDefaultsToml;
|
|
use codex_config::WindowsRequirementsToml;
|
|
use codex_config::types::FeedbackConfigToml;
|
|
use codex_config::types::ToolSuggestDisabledTool;
|
|
use codex_exec_server::EnvironmentManager;
|
|
use codex_login::CodexAuth;
|
|
use codex_protocol::config_types::ForcedLoginMethod;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
#[tokio::test]
|
|
async fn reload_user_config_preserves_promoted_mcp_allowlist_denial() -> anyhow::Result<()> {
|
|
let home = tempfile::tempdir()?;
|
|
let managed_config_path = home.path().join("managed_config.toml");
|
|
std::fs::write(
|
|
&managed_config_path,
|
|
r#"
|
|
[features]
|
|
use_xaa = true
|
|
[mcp_enterprise_managed_auth.idp]
|
|
issuer = "https://idp.example"
|
|
client_id = "idp-client"
|
|
[mcp_servers.enterprise]
|
|
url = "https://resource.example/mcp"
|
|
auth = "ema_auth"
|
|
oauth_resource = "https://resource.example/mcp"
|
|
[mcp_servers.enterprise.oauth]
|
|
client_id = "mcp-client"
|
|
"#,
|
|
)?;
|
|
let requirements_path = home.path().join("requirements.toml");
|
|
std::fs::write(
|
|
&requirements_path,
|
|
"[mcp_servers.enterprise.identity]\nurl = 'https://resource.example/mcp'\n",
|
|
)?;
|
|
let config_manager = ConfigManager::new_for_tests(
|
|
home.path().to_path_buf(),
|
|
Vec::new(),
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_config_path),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
let initial = config_manager
|
|
.load_latest_config(Some(home.path().to_path_buf()))
|
|
.await?;
|
|
let thread_manager = codex_core::test_support::thread_manager_with_models_provider_and_home(
|
|
CodexAuth::from_api_key("dummy"),
|
|
initial.model_provider.clone(),
|
|
initial.codex_home.to_path_buf(),
|
|
Arc::new(EnvironmentManager::default_for_tests()),
|
|
);
|
|
let thread = thread_manager
|
|
.start_thread(codex_core::StartThreadOptions::new(initial))
|
|
.await?
|
|
.thread;
|
|
assert!(thread.config().await.mcp_servers.get()["enterprise"].enabled);
|
|
|
|
std::fs::write(&requirements_path, "[mcp_servers]\n")?;
|
|
std::fs::write(
|
|
home.path().join(codex_config::CONFIG_TOML_FILE),
|
|
"[tool_suggest]\ndisabled_tools = [{ type = 'connector', id = 'calendar' }]\n",
|
|
)?;
|
|
reload_user_config(&config_manager, &thread_manager).await;
|
|
|
|
let refreshed = thread.config().await;
|
|
assert_eq!(
|
|
refreshed
|
|
.config_layer_stack
|
|
.requirements()
|
|
.mcp_servers
|
|
.as_ref()
|
|
.map(|requirements| &requirements.value),
|
|
Some(&BTreeMap::new())
|
|
);
|
|
assert!(!refreshed.mcp_servers.get()["enterprise"].enabled);
|
|
assert_eq!(
|
|
refreshed.tool_suggest.disabled_tools,
|
|
vec![ToolSuggestDisabledTool::connector("calendar")]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn map_test_requirements(
|
|
requirements: ConfigRequirementsToml,
|
|
) -> codex_app_server_protocol::ConfigRequirements {
|
|
map_requirements_to_api(
|
|
Some(requirements),
|
|
vec![ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt],
|
|
)
|
|
.expect("requirements")
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_allow_managed_hooks_only() {
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
allow_managed_hooks_only: Some(true),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
|
|
assert_eq!(mapped.hooks, None);
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_permission_default_and_allowlist() {
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
allowed_permission_profiles: Some(BTreeMap::from([
|
|
("managed-build".to_string(), false),
|
|
("managed-standard".to_string(), true),
|
|
])),
|
|
default_permissions: Some("managed-standard".to_string()),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(
|
|
mapped.allowed_permission_profiles,
|
|
Some(BTreeMap::from([
|
|
("managed-build".to_string(), false),
|
|
("managed-standard".to_string(), true),
|
|
]))
|
|
);
|
|
assert_eq!(
|
|
mapped.default_permissions,
|
|
Some("managed-standard".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_allow_appshots() {
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
allow_appshots: Some(false),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(mapped.allow_appshots, Some(false));
|
|
assert_eq!(mapped.hooks, None);
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_allow_remote_control() {
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
allow_remote_control: Some(false),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(mapped.allow_remote_control, Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_model_auto_review_and_new_thread_defaults() {
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
auto_review: Some(AutoReviewRequirementsToml {
|
|
required_on_models: Some(vec!["gpt-protected".to_string()]),
|
|
ignore_rules: Some(vec!["gpt-protected".to_string()]),
|
|
}),
|
|
models: Some(ModelsRequirementsToml {
|
|
new_thread: Some(NewThreadModelDefaultsToml {
|
|
model: Some("gpt-managed".to_string()),
|
|
model_reasoning_effort: Some(ReasoningEffort::Medium),
|
|
service_tier: Some("fast".to_string()),
|
|
}),
|
|
}),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(
|
|
mapped.auto_review,
|
|
Some(AutoReviewRequirements {
|
|
required_on_models: Some(vec!["gpt-protected".to_string()]),
|
|
ignore_rules: Some(vec!["gpt-protected".to_string()]),
|
|
})
|
|
);
|
|
let models = mapped.models.expect("managed model requirements");
|
|
let defaults = models.new_thread.expect("new-thread defaults");
|
|
assert_eq!(defaults.model.as_deref(), Some("gpt-managed"));
|
|
assert_eq!(
|
|
defaults.model_reasoning_effort,
|
|
Some(ReasoningEffort::Medium)
|
|
);
|
|
assert_eq!(defaults.service_tier.as_deref(), Some("fast"));
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_browser_and_computer_use_requirements() {
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
allow_browser_and_computer_use: Some(false),
|
|
browser_use: Some(BrowserUseRequirementsToml {
|
|
extension: Some(codex_config::BrowserUseExtensionRequirementsToml {
|
|
request_headers: Some(vec![codex_config::RequestHeaderToml {
|
|
name: "x-browser-agent".into(),
|
|
value: "ChatGPT/{{session_id}}".into(),
|
|
}]),
|
|
}),
|
|
allow_webmcp: Some(true),
|
|
allow_history_access: Some(false),
|
|
disable_auto_review: Some(true),
|
|
allow_global_persistent_approval: Some(false),
|
|
default_origin_policy: Some(BrowserUseOriginPolicyToml {
|
|
access: Some(AllowDenyRequirementToml::Deny),
|
|
downloads: Some(AllowDenyRequirementToml::Allow),
|
|
uploads: Some(AllowDenyRequirementToml::Deny),
|
|
full_cdp_access: Some(AllowDenyRequirementToml::Allow),
|
|
auto_review: Some(AllowDenyRequirementToml::Deny),
|
|
persistent_approval: Some(false),
|
|
access_approval_lifetime: Some(BrowserUseAccessApprovalLifetimeToml::Turn),
|
|
}),
|
|
origins: Some(BTreeMap::from([(
|
|
"https://example.com".to_string(),
|
|
BrowserUseOriginPolicyToml {
|
|
access: Some(AllowDenyRequirementToml::Allow),
|
|
downloads: Some(AllowDenyRequirementToml::Deny),
|
|
uploads: Some(AllowDenyRequirementToml::Allow),
|
|
full_cdp_access: Some(AllowDenyRequirementToml::Deny),
|
|
auto_review: Some(AllowDenyRequirementToml::Deny),
|
|
persistent_approval: Some(true),
|
|
access_approval_lifetime: Some(
|
|
BrowserUseAccessApprovalLifetimeToml::Thread,
|
|
),
|
|
},
|
|
)])),
|
|
}),
|
|
computer_use: Some(ComputerUseRequirementsToml {
|
|
allow_locked_computer_use: Some(false),
|
|
allow_persistent_approval: Some(false),
|
|
default_app_access: Some(AllowDenyRequirementToml::Deny),
|
|
macos: Some(ComputerUseMacosRequirementsToml {
|
|
bundle_ids: Some(BTreeMap::from([(
|
|
"com.apple.Safari".to_string(),
|
|
AllowDenyRequirementToml::Allow,
|
|
)])),
|
|
}),
|
|
windows: Some(ComputerUseWindowsRequirementsToml {
|
|
aumids: Some(BTreeMap::from([(
|
|
"Microsoft.Paint_8wekyb3d8bbwe!App".to_string(),
|
|
AllowDenyRequirementToml::Allow,
|
|
)])),
|
|
exes: Some(vec![ComputerUseWindowsExeRequirementToml {
|
|
publisher_name: "CN=Google LLC".to_string(),
|
|
product_name: "Google Chrome".to_string(),
|
|
binary_name: Some("chrome.exe".to_string()),
|
|
access: AllowDenyRequirementToml::Deny,
|
|
}]),
|
|
}),
|
|
}),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(mapped.allow_browser_and_computer_use, Some(false));
|
|
assert_eq!(
|
|
mapped.browser_use,
|
|
Some(BrowserUseRequirements {
|
|
extension: Some(codex_app_server_protocol::BrowserUseExtensionRequirements {
|
|
request_headers: Some(vec![codex_app_server_protocol::RequestHeader {
|
|
name: "x-browser-agent".into(),
|
|
value: "ChatGPT/{{session_id}}".into()
|
|
}]),
|
|
}),
|
|
allow_webmcp: Some(true),
|
|
allow_history_access: Some(false),
|
|
disable_auto_review: Some(true),
|
|
allow_global_persistent_approval: Some(false),
|
|
default_origin_policy: Some(BrowserUseOriginPolicy {
|
|
access: Some(AllowDenyRequirement::Deny),
|
|
downloads: Some(AllowDenyRequirement::Allow),
|
|
uploads: Some(AllowDenyRequirement::Deny),
|
|
full_cdp_access: Some(AllowDenyRequirement::Allow),
|
|
auto_review: Some(AllowDenyRequirement::Deny),
|
|
persistent_approval: Some(false),
|
|
access_approval_lifetime: Some(BrowserUseAccessApprovalLifetime::Turn),
|
|
}),
|
|
origins: Some(BTreeMap::from([(
|
|
"https://example.com".to_string(),
|
|
BrowserUseOriginPolicy {
|
|
access: Some(AllowDenyRequirement::Allow),
|
|
downloads: Some(AllowDenyRequirement::Deny),
|
|
uploads: Some(AllowDenyRequirement::Allow),
|
|
full_cdp_access: Some(AllowDenyRequirement::Deny),
|
|
auto_review: Some(AllowDenyRequirement::Deny),
|
|
persistent_approval: Some(true),
|
|
access_approval_lifetime: Some(BrowserUseAccessApprovalLifetime::Thread),
|
|
},
|
|
)])),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
mapped.computer_use,
|
|
Some(ComputerUseRequirements {
|
|
allow_locked_computer_use: Some(false),
|
|
allow_persistent_approval: Some(false),
|
|
default_app_access: Some(AllowDenyRequirement::Deny),
|
|
macos: Some(ComputerUseMacosRequirements {
|
|
bundle_ids: Some(BTreeMap::from([(
|
|
"com.apple.Safari".to_string(),
|
|
AllowDenyRequirement::Allow,
|
|
)])),
|
|
}),
|
|
windows: Some(ComputerUseWindowsRequirements {
|
|
aumids: Some(BTreeMap::from([(
|
|
"Microsoft.Paint_8wekyb3d8bbwe!App".to_string(),
|
|
AllowDenyRequirement::Allow,
|
|
)])),
|
|
exes: Some(vec![ComputerUseWindowsExeRequirement {
|
|
publisher_name: "CN=Google LLC".to_string(),
|
|
product_name: "Google Chrome".to_string(),
|
|
binary_name: Some("chrome.exe".to_string()),
|
|
access: AllowDenyRequirement::Deny,
|
|
}]),
|
|
}),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_allowed_windows_sandbox_implementations() {
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
windows: Some(WindowsRequirementsToml {
|
|
allowed_sandbox_implementations: Some(vec![
|
|
codex_config::WindowsSandboxImplementationToml::Elevated,
|
|
codex_config::WindowsSandboxImplementationToml::Unelevated,
|
|
]),
|
|
allow_mxc: None,
|
|
require_mxc: None,
|
|
}),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(
|
|
mapped.allowed_windows_sandbox_implementations,
|
|
Some(vec![
|
|
WindowsSandboxImplementation::Elevated,
|
|
WindowsSandboxImplementation::Unelevated,
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn requirements_api_includes_exact_managed_values() {
|
|
let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state"))
|
|
.expect("managed sqlite home should be absolute");
|
|
let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs"))
|
|
.expect("managed log dir should be absolute");
|
|
let model_catalog_json =
|
|
AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json"))
|
|
.expect("managed model catalog path should be absolute");
|
|
let mapped = map_test_requirements(ConfigRequirementsToml {
|
|
sqlite_home: Some(sqlite_home.clone()),
|
|
log_dir: Some(log_dir.clone()),
|
|
model_catalog_json: Some(model_catalog_json.clone()),
|
|
check_for_update_on_startup: Some(false),
|
|
allow_login_shell: Some(false),
|
|
feedback: Some(FeedbackConfigToml {
|
|
enabled: Some(false),
|
|
}),
|
|
..ConfigRequirementsToml::default()
|
|
});
|
|
|
|
assert_eq!(mapped.sqlite_home, Some(PathUri::from(sqlite_home)));
|
|
assert_eq!(mapped.log_dir, Some(PathUri::from(log_dir)));
|
|
assert_eq!(
|
|
mapped.model_catalog_json,
|
|
Some(PathUri::from(model_catalog_json))
|
|
);
|
|
assert_eq!(mapped.check_for_update_on_startup, Some(false));
|
|
assert_eq!(mapped.allow_login_shell, Some(false));
|
|
assert_eq!(
|
|
mapped.feedback,
|
|
Some(FeedbackRequirements {
|
|
enabled: Some(false),
|
|
})
|
|
);
|
|
}
|
|
}
|