mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
9c3abcd46c
## Why Config loading had become split across crates: `codex-config` owned the config types and merge logic, while `codex-core` still owned the loader that assembled the layer stack. This change consolidates that responsibility in `codex-config`, so the crate that defines config behavior also owns how configs are discovered and loaded. To make that move possible without reintroducing the old dependency cycle, the shell-environment policy types and helpers that `codex-exec-server` needs now live in `codex-protocol` instead of flowing through `codex-config`. This also makes the migrated loader tests more deterministic on machines that already have managed or system Codex config installed by letting tests override the system config and requirements paths instead of reading the host's `/etc/codex`. ## What Changed - moved the config loader implementation from `codex-core` into `codex-config::loader` and deleted the old `core::config_loader` module instead of leaving a compatibility shim - moved shell-environment policy types and helpers into `codex-protocol`, then updated `codex-exec-server` and other downstream crates to import them from their new home - updated downstream callers to use loader/config APIs from `codex-config` - added test-only loader overrides for system config and requirements paths so loader-focused tests do not depend on host-managed config state - cleaned up now-unused dependency entries and platform-specific cfgs that were surfaced by post-push CI ## Testing - `cargo test -p codex-config` - `cargo test -p codex-core config_loader_tests::` - `cargo test -p codex-protocol -p codex-exec-server -p codex-cloud-requirements -p codex-rmcp-client --lib` - `cargo test --lib -p codex-app-server-client -p codex-exec` - `cargo test --no-run --lib -p codex-app-server` - `cargo test -p codex-linux-sandbox --lib` - `cargo shear` - `just bazel-lock-check` ## Notes - I did not chase unrelated full-suite failures outside the migrated loader surface. - `cargo test -p codex-core --lib` still hits unrelated proxy-sensitive failures on this machine, and Windows CI still shows unrelated long-running/timeouting test noise outside the loader migration itself.
871 lines
32 KiB
Rust
871 lines
32 KiB
Rust
use crate::config_manager::ConfigManager;
|
|
use crate::config_manager_service::ConfigManagerError;
|
|
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
|
use crate::error_code::internal_error;
|
|
use crate::error_code::invalid_request;
|
|
use async_trait::async_trait;
|
|
use codex_analytics::AnalyticsEventsClient;
|
|
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::JSONRPCErrorError;
|
|
use codex_app_server_protocol::ManagedHooksRequirements;
|
|
use codex_app_server_protocol::NetworkDomainPermission;
|
|
use codex_app_server_protocol::NetworkRequirements;
|
|
use codex_app_server_protocol::NetworkUnixSocketPermission;
|
|
use codex_app_server_protocol::SandboxMode;
|
|
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_core::config::Config;
|
|
use codex_core::plugins::PluginId;
|
|
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
|
|
use codex_core_plugins::toggles::collect_plugin_enabled_candidates;
|
|
use codex_features::canonical_feature_for_key;
|
|
use codex_features::feature_for_key;
|
|
use codex_protocol::config_types::WebSearchMode;
|
|
use codex_protocol::protocol::Op;
|
|
use serde_json::json;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use tracing::warn;
|
|
|
|
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
|
"apps",
|
|
"memories",
|
|
"plugins",
|
|
"tool_search",
|
|
"tool_suggest",
|
|
"tool_call_mcp_elicitation",
|
|
];
|
|
|
|
#[async_trait]
|
|
pub(crate) trait UserConfigReloader: Send + Sync {
|
|
async fn reload_user_config(&self);
|
|
}
|
|
|
|
#[async_trait]
|
|
impl UserConfigReloader for ThreadManager {
|
|
async fn reload_user_config(&self) {
|
|
let thread_ids = self.list_thread_ids().await;
|
|
for thread_id in thread_ids {
|
|
let Ok(thread) = self.get_thread(thread_id).await else {
|
|
continue;
|
|
};
|
|
if let Err(err) = thread.submit(Op::ReloadUserConfig).await {
|
|
warn!("failed to request user config reload: {err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ConfigApi {
|
|
config_manager: ConfigManager,
|
|
user_config_reloader: Arc<dyn UserConfigReloader>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
}
|
|
|
|
impl ConfigApi {
|
|
pub(crate) fn new(
|
|
config_manager: ConfigManager,
|
|
user_config_reloader: Arc<dyn UserConfigReloader>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
) -> Self {
|
|
Self {
|
|
config_manager,
|
|
user_config_reloader,
|
|
analytics_events_client,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn load_latest_config(
|
|
&self,
|
|
fallback_cwd: Option<PathBuf>,
|
|
) -> Result<Config, JSONRPCErrorError> {
|
|
self.config_manager
|
|
.load_latest_config(fallback_cwd)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!(
|
|
"failed to resolve feature override precedence: {err}"
|
|
))
|
|
})
|
|
}
|
|
|
|
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)?
|
|
.map(map_requirements_toml_to_api);
|
|
|
|
Ok(ConfigRequirementsReadResponse { requirements })
|
|
}
|
|
|
|
pub(crate) async fn write_value(
|
|
&self,
|
|
params: ConfigValueWriteParams,
|
|
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
|
let pending_changes =
|
|
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)
|
|
}
|
|
|
|
pub(crate) async fn batch_write(
|
|
&self,
|
|
params: ConfigBatchWriteParams,
|
|
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
|
let reload_user_config = params.reload_user_config;
|
|
let pending_changes = 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;
|
|
if reload_user_config {
|
|
self.user_config_reloader.reload_user_config().await;
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
pub(crate) async fn set_experimental_feature_enablement(
|
|
&self,
|
|
params: ExperimentalFeatureEnablementSetParams,
|
|
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
|
|
let ExperimentalFeatureEnablementSetParams { enablement } = params;
|
|
for key in enablement.keys() {
|
|
if canonical_feature_for_key(key).is_some() {
|
|
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
|
|
continue;
|
|
}
|
|
|
|
return Err(invalid_request(format!(
|
|
"unsupported feature enablement `{key}`: currently supported features are {}",
|
|
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
|
|
)));
|
|
}
|
|
|
|
let message = if let Some(feature) = feature_for_key(key) {
|
|
format!(
|
|
"invalid feature enablement `{key}`: use canonical feature key `{}`",
|
|
feature.key()
|
|
)
|
|
} else {
|
|
format!("invalid feature enablement `{key}`")
|
|
};
|
|
return Err(invalid_request(message));
|
|
}
|
|
|
|
if enablement.is_empty() {
|
|
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
|
|
}
|
|
|
|
self.config_manager
|
|
.extend_runtime_feature_enablement(
|
|
enablement
|
|
.iter()
|
|
.map(|(name, enabled)| (name.clone(), *enabled)),
|
|
)
|
|
.map_err(|_| internal_error("failed to update feature enablement"))?;
|
|
|
|
self.load_latest_config(/*fallback_cwd*/ None).await?;
|
|
self.user_config_reloader.reload_user_config().await;
|
|
|
|
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
|
|
}
|
|
|
|
async fn emit_plugin_toggle_events(
|
|
&self,
|
|
pending_changes: std::collections::BTreeMap<String, bool>,
|
|
) {
|
|
for (plugin_id, enabled) in pending_changes {
|
|
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
|
|
continue;
|
|
};
|
|
let metadata =
|
|
installed_plugin_telemetry_metadata(self.config_manager.codex_home(), &plugin_id)
|
|
.await;
|
|
if enabled {
|
|
self.analytics_events_client.track_plugin_enabled(metadata);
|
|
} else {
|
|
self.analytics_events_client.track_plugin_disabled(metadata);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
|
|
ConfigRequirements {
|
|
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_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
|
|
}),
|
|
feature_requirements: requirements
|
|
.feature_requirements
|
|
.map(|requirements| requirements.entries),
|
|
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
|
|
enforce_residency: requirements
|
|
.enforce_residency
|
|
.map(map_residency_requirement_to_api),
|
|
network: requirements.network.map(map_network_requirements_to_api),
|
|
}
|
|
}
|
|
|
|
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
|
|
let ManagedHooksRequirementsToml {
|
|
managed_dir,
|
|
windows_managed_dir,
|
|
hooks,
|
|
} = hooks;
|
|
let HookEventsToml {
|
|
pre_tool_use,
|
|
permission_request,
|
|
post_tool_use,
|
|
session_start,
|
|
user_prompt_submit,
|
|
stop,
|
|
} = hooks;
|
|
|
|
ManagedHooksRequirements {
|
|
managed_dir,
|
|
windows_managed_dir,
|
|
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),
|
|
session_start: map_hook_matcher_groups_to_api(session_start),
|
|
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
|
|
stop: map_hook_matcher_groups_to_api(stop),
|
|
}
|
|
}
|
|
|
|
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,
|
|
timeout_sec,
|
|
r#async,
|
|
status_message,
|
|
} => ConfiguredHookHandler::Command {
|
|
command,
|
|
timeout_sec,
|
|
r#async,
|
|
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()
|
|
}),
|
|
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::None => NetworkUnixSocketPermission::None,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
JSONRPCErrorError {
|
|
code: INVALID_REQUEST_ERROR_CODE,
|
|
message: message.into(),
|
|
data: Some(json!({
|
|
"config_write_error_code": code,
|
|
})),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config_manager::apply_runtime_feature_enablement;
|
|
use codex_analytics::AnalyticsEventsClient;
|
|
use codex_arg0::Arg0DispatchPaths;
|
|
use codex_config::CloudRequirementsLoader;
|
|
use codex_config::LoaderOverrides;
|
|
use codex_config::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml;
|
|
use codex_config::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml;
|
|
use codex_config::NetworkRequirementsToml as CoreNetworkRequirementsToml;
|
|
use codex_config::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml;
|
|
use codex_config::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml;
|
|
use codex_features::Feature;
|
|
use codex_login::AuthManager;
|
|
use codex_login::CodexAuth;
|
|
use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer;
|
|
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::collections::BTreeMap;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use tempfile::TempDir;
|
|
use toml::Value as TomlValue;
|
|
|
|
#[derive(Default)]
|
|
struct RecordingUserConfigReloader {
|
|
call_count: AtomicUsize,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl UserConfigReloader for RecordingUserConfigReloader {
|
|
async fn reload_user_config(&self) {
|
|
self.call_count.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn map_requirements_toml_to_api_converts_core_enums() {
|
|
let requirements = ConfigRequirementsToml {
|
|
allowed_approval_policies: Some(vec![
|
|
CoreAskForApproval::Never,
|
|
CoreAskForApproval::OnRequest,
|
|
]),
|
|
allowed_approvals_reviewers: Some(vec![
|
|
CoreApprovalsReviewer::User,
|
|
CoreApprovalsReviewer::AutoReview,
|
|
]),
|
|
allowed_sandbox_modes: Some(vec![
|
|
CoreSandboxModeRequirement::ReadOnly,
|
|
CoreSandboxModeRequirement::ExternalSandbox,
|
|
]),
|
|
remote_sandbox_config: None,
|
|
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
|
|
guardian_policy_config: None,
|
|
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
|
entries: std::collections::BTreeMap::from([
|
|
("apps".to_string(), false),
|
|
("personality".to_string(), true),
|
|
]),
|
|
}),
|
|
hooks: Some(ManagedHooksRequirementsToml {
|
|
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
|
|
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
|
|
hooks: HookEventsToml {
|
|
pre_tool_use: vec![CoreMatcherGroup {
|
|
matcher: Some("^Bash$".to_string()),
|
|
hooks: vec![CoreHookHandlerConfig::Command {
|
|
command: "python3 /enterprise/hooks/pre.py".to_string(),
|
|
timeout_sec: Some(10),
|
|
r#async: false,
|
|
status_message: Some("checking".to_string()),
|
|
}],
|
|
}],
|
|
..Default::default()
|
|
},
|
|
}),
|
|
mcp_servers: None,
|
|
apps: None,
|
|
rules: None,
|
|
enforce_residency: Some(CoreResidencyRequirement::Us),
|
|
network: Some(CoreNetworkRequirementsToml {
|
|
enabled: Some(true),
|
|
http_port: Some(8080),
|
|
socks_port: Some(1080),
|
|
allow_upstream_proxy: Some(false),
|
|
dangerously_allow_non_loopback_proxy: Some(false),
|
|
dangerously_allow_all_unix_sockets: Some(true),
|
|
domains: Some(CoreNetworkDomainPermissionsToml {
|
|
entries: std::collections::BTreeMap::from([
|
|
(
|
|
"api.openai.com".to_string(),
|
|
CoreNetworkDomainPermissionToml::Allow,
|
|
),
|
|
(
|
|
"example.com".to_string(),
|
|
CoreNetworkDomainPermissionToml::Deny,
|
|
),
|
|
]),
|
|
}),
|
|
managed_allowed_domains_only: Some(false),
|
|
unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml {
|
|
entries: std::collections::BTreeMap::from([(
|
|
"/tmp/proxy.sock".to_string(),
|
|
CoreNetworkUnixSocketPermissionToml::Allow,
|
|
)]),
|
|
}),
|
|
allow_local_binding: Some(true),
|
|
}),
|
|
permissions: None,
|
|
};
|
|
|
|
let mapped = map_requirements_toml_to_api(requirements);
|
|
|
|
assert_eq!(
|
|
mapped.allowed_approval_policies,
|
|
Some(vec![
|
|
codex_app_server_protocol::AskForApproval::Never,
|
|
codex_app_server_protocol::AskForApproval::OnRequest,
|
|
])
|
|
);
|
|
assert_eq!(
|
|
mapped.allowed_approvals_reviewers,
|
|
Some(vec![
|
|
codex_app_server_protocol::ApprovalsReviewer::User,
|
|
codex_app_server_protocol::ApprovalsReviewer::AutoReview,
|
|
])
|
|
);
|
|
assert_eq!(
|
|
mapped.allowed_sandbox_modes,
|
|
Some(vec![SandboxMode::ReadOnly]),
|
|
);
|
|
assert_eq!(
|
|
mapped.allowed_web_search_modes,
|
|
Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]),
|
|
);
|
|
assert_eq!(
|
|
mapped.feature_requirements,
|
|
Some(std::collections::BTreeMap::from([
|
|
("apps".to_string(), false),
|
|
("personality".to_string(), true),
|
|
])),
|
|
);
|
|
assert_eq!(
|
|
mapped.hooks,
|
|
Some(ManagedHooksRequirements {
|
|
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
|
|
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
|
|
pre_tool_use: vec![ConfiguredHookMatcherGroup {
|
|
matcher: Some("^Bash$".to_string()),
|
|
hooks: vec![ConfiguredHookHandler::Command {
|
|
command: "python3 /enterprise/hooks/pre.py".to_string(),
|
|
timeout_sec: Some(10),
|
|
r#async: false,
|
|
status_message: Some("checking".to_string()),
|
|
}],
|
|
}],
|
|
permission_request: Vec::new(),
|
|
post_tool_use: Vec::new(),
|
|
session_start: Vec::new(),
|
|
user_prompt_submit: Vec::new(),
|
|
stop: Vec::new(),
|
|
}),
|
|
);
|
|
assert_eq!(
|
|
mapped.enforce_residency,
|
|
Some(codex_app_server_protocol::ResidencyRequirement::Us),
|
|
);
|
|
assert_eq!(
|
|
mapped.network,
|
|
Some(NetworkRequirements {
|
|
enabled: Some(true),
|
|
http_port: Some(8080),
|
|
socks_port: Some(1080),
|
|
allow_upstream_proxy: Some(false),
|
|
dangerously_allow_non_loopback_proxy: Some(false),
|
|
dangerously_allow_all_unix_sockets: Some(true),
|
|
domains: Some(std::collections::BTreeMap::from([
|
|
("api.openai.com".to_string(), NetworkDomainPermission::Allow,),
|
|
("example.com".to_string(), NetworkDomainPermission::Deny),
|
|
])),
|
|
managed_allowed_domains_only: Some(false),
|
|
allowed_domains: Some(vec!["api.openai.com".to_string()]),
|
|
denied_domains: Some(vec!["example.com".to_string()]),
|
|
unix_sockets: Some(std::collections::BTreeMap::from([(
|
|
"/tmp/proxy.sock".to_string(),
|
|
NetworkUnixSocketPermission::Allow,
|
|
)])),
|
|
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
|
|
allow_local_binding: Some(true),
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn map_requirements_toml_to_api_omits_unix_socket_none_entries_from_legacy_network_fields() {
|
|
let requirements = ConfigRequirementsToml {
|
|
allowed_approval_policies: None,
|
|
allowed_approvals_reviewers: None,
|
|
allowed_sandbox_modes: None,
|
|
remote_sandbox_config: None,
|
|
allowed_web_search_modes: None,
|
|
guardian_policy_config: None,
|
|
feature_requirements: None,
|
|
hooks: None,
|
|
mcp_servers: None,
|
|
apps: None,
|
|
rules: None,
|
|
enforce_residency: None,
|
|
network: Some(CoreNetworkRequirementsToml {
|
|
enabled: None,
|
|
http_port: None,
|
|
socks_port: None,
|
|
allow_upstream_proxy: None,
|
|
dangerously_allow_non_loopback_proxy: None,
|
|
dangerously_allow_all_unix_sockets: None,
|
|
domains: None,
|
|
managed_allowed_domains_only: None,
|
|
unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml {
|
|
entries: std::collections::BTreeMap::from([(
|
|
"/tmp/ignored.sock".to_string(),
|
|
CoreNetworkUnixSocketPermissionToml::None,
|
|
)]),
|
|
}),
|
|
allow_local_binding: None,
|
|
}),
|
|
permissions: None,
|
|
};
|
|
|
|
let mapped = map_requirements_toml_to_api(requirements);
|
|
|
|
assert_eq!(
|
|
mapped.network,
|
|
Some(NetworkRequirements {
|
|
enabled: None,
|
|
http_port: None,
|
|
socks_port: None,
|
|
allow_upstream_proxy: None,
|
|
dangerously_allow_non_loopback_proxy: None,
|
|
dangerously_allow_all_unix_sockets: None,
|
|
domains: None,
|
|
managed_allowed_domains_only: None,
|
|
allowed_domains: None,
|
|
denied_domains: None,
|
|
unix_sockets: Some(std::collections::BTreeMap::from([(
|
|
"/tmp/ignored.sock".to_string(),
|
|
NetworkUnixSocketPermission::None,
|
|
)])),
|
|
allow_unix_sockets: None,
|
|
allow_local_binding: None,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() {
|
|
let requirements = ConfigRequirementsToml {
|
|
allowed_approval_policies: None,
|
|
allowed_approvals_reviewers: None,
|
|
allowed_sandbox_modes: None,
|
|
remote_sandbox_config: None,
|
|
allowed_web_search_modes: Some(Vec::new()),
|
|
guardian_policy_config: None,
|
|
feature_requirements: None,
|
|
hooks: None,
|
|
mcp_servers: None,
|
|
apps: None,
|
|
rules: None,
|
|
enforce_residency: None,
|
|
network: None,
|
|
permissions: None,
|
|
};
|
|
|
|
let mapped = map_requirements_toml_to_api(requirements);
|
|
|
|
assert_eq!(
|
|
mapped.allowed_web_search_modes,
|
|
Some(vec![WebSearchMode::Disabled])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() {
|
|
let codex_home = TempDir::new().expect("create temp dir");
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
"[features]\napps = false\n",
|
|
)
|
|
.expect("write config");
|
|
|
|
let mut config = codex_core::config::ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
|
.cli_overrides(vec![(
|
|
"features.apps".to_string(),
|
|
TomlValue::Boolean(true),
|
|
)])
|
|
.build()
|
|
.await
|
|
.expect("load config");
|
|
|
|
apply_runtime_feature_enablement(
|
|
&mut config,
|
|
&BTreeMap::from([("apps".to_string(), false)]),
|
|
);
|
|
|
|
assert!(config.features.enabled(Feature::Apps));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() {
|
|
let codex_home = TempDir::new().expect("create temp dir");
|
|
|
|
let mut config = codex_core::config::ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.cli_overrides(vec![(
|
|
"features.apps".to_string(),
|
|
TomlValue::Boolean(true),
|
|
)])
|
|
.cloud_requirements(CloudRequirementsLoader::new(async {
|
|
Ok(Some(ConfigRequirementsToml {
|
|
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
|
entries: BTreeMap::from([("apps".to_string(), false)]),
|
|
}),
|
|
..Default::default()
|
|
}))
|
|
}))
|
|
.build()
|
|
.await
|
|
.expect("load config");
|
|
|
|
apply_runtime_feature_enablement(
|
|
&mut config,
|
|
&BTreeMap::from([("apps".to_string(), true)]),
|
|
);
|
|
|
|
assert!(!config.features.enabled(Feature::Apps));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_write_reloads_user_config_when_requested() {
|
|
let codex_home = TempDir::new().expect("create temp dir");
|
|
let user_config_path = codex_home.path().join("config.toml");
|
|
std::fs::write(&user_config_path, "").expect("write config");
|
|
let reloader = Arc::new(RecordingUserConfigReloader::default());
|
|
let analytics_config = Arc::new(
|
|
codex_core::config::ConfigBuilder::default()
|
|
.build()
|
|
.await
|
|
.expect("load analytics config"),
|
|
);
|
|
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
|
let config_api = ConfigApi::new(
|
|
ConfigManager::new(
|
|
codex_home.path().to_path_buf(),
|
|
Vec::new(),
|
|
LoaderOverrides::default(),
|
|
CloudRequirementsLoader::default(),
|
|
Arg0DispatchPaths::default(),
|
|
Arc::new(codex_config::NoopThreadConfigLoader),
|
|
),
|
|
reloader.clone(),
|
|
AnalyticsEventsClient::new(
|
|
auth_manager,
|
|
analytics_config
|
|
.chatgpt_base_url
|
|
.trim_end_matches('/')
|
|
.to_string(),
|
|
analytics_config.analytics_enabled,
|
|
),
|
|
);
|
|
|
|
let response = config_api
|
|
.batch_write(ConfigBatchWriteParams {
|
|
edits: vec![codex_app_server_protocol::ConfigEdit {
|
|
key_path: "model".to_string(),
|
|
value: json!("gpt-5"),
|
|
merge_strategy: codex_app_server_protocol::MergeStrategy::Replace,
|
|
}],
|
|
file_path: Some(user_config_path.display().to_string()),
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
})
|
|
.await
|
|
.expect("batch write should succeed");
|
|
|
|
assert_eq!(
|
|
response,
|
|
ConfigWriteResponse {
|
|
status: codex_app_server_protocol::WriteStatus::Ok,
|
|
version: response.version.clone(),
|
|
file_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(
|
|
user_config_path.clone()
|
|
)
|
|
.expect("absolute config path"),
|
|
overridden_metadata: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(user_config_path).unwrap(),
|
|
"model = \"gpt-5\"\n"
|
|
);
|
|
assert_eq!(reloader.call_count.load(Ordering::Relaxed), 1);
|
|
}
|
|
}
|