diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7d3720a96b..69a9f51b0e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1464,6 +1464,7 @@ dependencies = [ "codex-git-utils", "codex-login", "codex-mcp", + "codex-model-provider-info", "codex-models-manager", "codex-otel", "codex-protocol", @@ -1518,6 +1519,7 @@ dependencies = [ "codex-app-server", "codex-app-server-protocol", "codex-arg0", + "codex-config", "codex-core", "codex-exec-server", "codex-feedback", @@ -1866,6 +1868,7 @@ name = "codex-config" version = "0.0.0" dependencies = [ "anyhow", + "async-trait", "codex-app-server-protocol", "codex-execpolicy", "codex-features", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index 063b91c07c..d9c1ade097 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -15,6 +15,7 @@ workspace = true codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-feedback = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index be6bf52ad6..c6f678c2aa 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_config::NoopThreadConfigLoader; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -385,6 +386,7 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, cloud_requirements: self.cloud_requirements, + thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: self.feedback, log_db: self.log_db, environment_manager: self.environment_manager, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 097abdb713..f354539640 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -97,6 +97,7 @@ axum = { workspace = true, default-features = false, features = [ "tokio", ] } core_test_support = { workspace = true } +codex-model-provider-info = { workspace = true } codex-utils-cargo-bin = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0c0a257e75..6e0c7ce91f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -213,6 +213,7 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_cloud_requirements::cloud_requirements_loader; +use codex_config::ThreadConfigLoader; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::ForkSnapshot; @@ -479,6 +480,7 @@ pub(crate) struct CodexMessageProcessor { cli_overrides: Arc>>, runtime_feature_enablement: Arc>>, cloud_requirements: Arc>, + thread_config_loader: Arc, active_login: Arc>>, pending_thread_unloads: Arc>>, thread_state_manager: ThreadStateManager, @@ -639,6 +641,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) cli_overrides: Arc>>, pub(crate) runtime_feature_enablement: Arc>>, pub(crate) cloud_requirements: Arc>, + pub(crate) thread_config_loader: Arc, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, } @@ -726,6 +729,7 @@ impl CodexMessageProcessor { cli_overrides, runtime_feature_enablement, cloud_requirements, + thread_config_loader, feedback, log_db, } = args; @@ -740,6 +744,7 @@ impl CodexMessageProcessor { cli_overrides, runtime_feature_enablement, cloud_requirements, + thread_config_loader, active_login: Arc::new(Mutex::new(None)), pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), thread_state_manager: ThreadStateManager::new(), @@ -2433,12 +2438,14 @@ impl CodexMessageProcessor { }; let request_trace = request_context.request_trace(); let runtime_feature_enablement = self.current_runtime_feature_enablement(); + let thread_config_loader = Arc::clone(&self.thread_config_loader); let thread_start_task = async move { Self::thread_start_task( listener_task_context, cli_overrides, runtime_feature_enablement, cloud_requirements, + thread_config_loader, request_id, app_server_client_name, app_server_client_version, @@ -2515,6 +2522,7 @@ impl CodexMessageProcessor { cli_overrides: Vec<(String, TomlValue)>, runtime_feature_enablement: BTreeMap, cloud_requirements: CloudRequirementsLoader, + thread_config_loader: Arc, request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, @@ -2532,6 +2540,7 @@ impl CodexMessageProcessor { &cli_overrides, config_overrides.clone(), typesafe_overrides.clone(), + Arc::clone(&thread_config_loader), &cloud_requirements, &listener_task_context.codex_home, &runtime_feature_enablement, @@ -2613,6 +2622,7 @@ impl CodexMessageProcessor { cli_overrides_for_reload, config_overrides, typesafe_overrides, + thread_config_loader, &cloud_requirements, &listener_task_context.codex_home, &runtime_feature_enablement, @@ -6528,6 +6538,7 @@ impl CodexMessageProcessor { &cli_overrides, LoaderOverrides::default(), CloudRequirementsLoader::default(), + self.thread_config_loader.as_ref(), ) .await { @@ -9452,6 +9463,7 @@ async fn derive_config_from_params( cli_overrides: &[(String, TomlValue)], request_overrides: Option>, typesafe_overrides: ConfigOverrides, + thread_config_loader: Arc, cloud_requirements: &CloudRequirementsLoader, codex_home: &Path, runtime_feature_enablement: &BTreeMap, @@ -9472,6 +9484,7 @@ async fn derive_config_from_params( .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .cloud_requirements(cloud_requirements.clone()) + .thread_config_loader(thread_config_loader) .build() .await?; apply_runtime_feature_enablement(&mut config, runtime_feature_enablement); @@ -10402,6 +10415,11 @@ mod tests { use chrono::Utc; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_config::SessionThreadConfig; + use codex_config::StaticThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -10414,6 +10432,7 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; + use std::sync::Arc; use tempfile::TempDir; #[test] @@ -10566,6 +10585,64 @@ mod tests { ); } + #[tokio::test] + async fn derive_config_from_params_uses_session_thread_config_model_provider() -> Result<()> { + let temp_dir = TempDir::new()?; + let session_provider = ModelProviderInfo { + name: "session".to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: true, + }; + let config = derive_config_from_params( + &[], + Some(HashMap::from([ + ("model_provider".to_string(), json!("request")), + ("features.plugins".to_string(), json!(true)), + ( + "model_providers.session".to_string(), + json!({ + "name": "request", + "base_url": "http://127.0.0.1:9999/api/codex", + "wire_api": "responses", + }), + ), + ])), + ConfigOverrides::default(), + Arc::new(StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("session".to_string()), + model_providers: HashMap::from([( + "session".to_string(), + session_provider.clone(), + )]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ])), + &CloudRequirementsLoader::default(), + temp_dir.path(), + &BTreeMap::new(), + ) + .await?; + + assert_eq!(config.model_provider_id, "session"); + assert_eq!(config.model_provider, session_provider); + assert!(!config.features.enabled(Feature::Plugins)); + Ok(()) + } + #[test] fn collect_resume_override_mismatches_includes_service_tier() { let request = ThreadResumeParams { diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4458bce89d..e73124c0d3 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -116,6 +117,8 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, + /// Loader used to fetch typed thread config sources before a thread starts. + pub thread_config_loader: Arc, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. @@ -397,6 +400,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { cli_overrides: args.cli_overrides, loader_overrides: args.loader_overrides, cloud_requirements: args.cloud_requirements, + thread_config_loader: args.thread_config_loader, feedback: args.feedback, log_db: args.log_db, config_warnings: args.config_warnings, @@ -731,6 +735,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index b7b099960b..d9c108033f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -2,6 +2,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader; +use codex_config::NoopThreadConfigLoader; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config_loader::CloudRequirementsLoader; @@ -416,6 +417,7 @@ pub async fn run_main_with_transport( } }; let loader_overrides_for_config_api = loader_overrides.clone(); + let thread_config_loader = Arc::new(NoopThreadConfigLoader); let mut config_warnings = Vec::new(); let config = match ConfigBuilder::default() .cli_overrides(cli_kv_overrides.clone()) @@ -659,6 +661,7 @@ pub async fn run_main_with_transport( cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), + thread_config_loader, feedback: feedback.clone(), log_db, config_warnings, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index da72af81c5..2032eb61cd 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -63,6 +63,7 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; use codex_chatgpt::connectors; +use codex_config::ThreadConfigLoader; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; @@ -235,6 +236,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, + pub(crate) thread_config_loader: Arc, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, pub(crate) config_warnings: Vec, @@ -256,6 +258,7 @@ impl MessageProcessor { cli_overrides, loader_overrides, cloud_requirements, + thread_config_loader, feedback, log_db, config_warnings, @@ -301,6 +304,7 @@ impl MessageProcessor { cli_overrides: cli_overrides.clone(), runtime_feature_enablement: runtime_feature_enablement.clone(), cloud_requirements: cloud_requirements.clone(), + thread_config_loader, feedback, log_db, }); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index c1bb995bab..adf583d8f1 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -245,6 +245,7 @@ fn build_test_processor( cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, config_warnings: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index b0ff8888a4..e09f5becab 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -175,6 +175,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 93ca283b86..4d70de2fdc 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] anyhow = { workspace = true } +async-trait = { workspace = true } codex-app-server-protocol = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index a26d5afe0a..3fc7e3ed3f 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -18,6 +18,7 @@ pub mod schema; pub mod shell_environment; mod skills_config; mod state; +mod thread_config; pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -93,5 +94,14 @@ pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; pub use state::ConfigLayerStackOrdering; pub use state::LoaderOverrides; +pub use thread_config::NoopThreadConfigLoader; +pub use thread_config::SessionThreadConfig; +pub use thread_config::StaticThreadConfigLoader; +pub use thread_config::ThreadConfigContext; +pub use thread_config::ThreadConfigLoadError; +pub use thread_config::ThreadConfigLoadErrorCode; +pub use thread_config::ThreadConfigLoader; +pub use thread_config::ThreadConfigSource; +pub use thread_config::UserThreadConfig; pub use codex_app_server_protocol::ConfigLayerSource; diff --git a/codex-rs/config/src/thread_config.rs b/codex-rs/config/src/thread_config.rs new file mode 100644 index 0000000000..bfdfa144d6 --- /dev/null +++ b/codex-rs/config/src/thread_config.rs @@ -0,0 +1,313 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; + +use async_trait::async_trait; +use codex_app_server_protocol::ConfigLayerSource; +use codex_model_provider_info::ModelProviderInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use thiserror::Error; +use toml::Value as TomlValue; + +use crate::ConfigLayerEntry; + +/// Context available to implementations when loading thread-scoped config. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ThreadConfigContext { + pub thread_id: Option, + pub cwd: Option, +} + +/// Config values owned by the service that starts or manages the session. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SessionThreadConfig { + pub model_provider: Option, + pub model_providers: HashMap, + pub features: BTreeMap, +} + +/// Config values owned by the authenticated user. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UserThreadConfig {} + +/// A typed config payload paired with the authority that produced it. +#[derive(Clone, Debug, PartialEq)] +pub enum ThreadConfigSource { + Session(SessionThreadConfig), + User(UserThreadConfig), +} + +/// Stable category for failures returned while loading thread config. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ThreadConfigLoadErrorCode { + Auth, + Timeout, + Parse, + RequestFailed, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct ThreadConfigLoadError { + code: ThreadConfigLoadErrorCode, + message: String, + status_code: Option, +} + +impl ThreadConfigLoadError { + pub fn new( + code: ThreadConfigLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> ThreadConfigLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +/// Loads typed config sources for a new thread. +/// +/// Implementations should fetch only the source-specific config they own and +/// return typed payloads without applying precedence or merge rules. Callers +/// are responsible for resolving the returned sources into the effective +/// runtime config. +#[async_trait] +pub trait ThreadConfigLoader: Send + Sync { + /// Load source-specific typed config. + /// + /// Implementations should keep this method focused on fetching and parsing + /// their owned sources. Most callers should use [`Self::load_config_layers`] + /// so precedence and merging continue through the ordinary config layer + /// stack. + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError>; + + async fn load_config_layers( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + let sources = self.load(context).await?; + sources + .into_iter() + .map(thread_config_source_to_layer) + .collect::, _>>() + .map(|layers| layers.into_iter().flatten().collect()) + } +} + +/// Loader backed by a static set of typed thread config sources. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StaticThreadConfigLoader { + sources: Vec, +} + +impl StaticThreadConfigLoader { + pub fn new(sources: Vec) -> Self { + Self { sources } + } +} + +#[async_trait] +impl ThreadConfigLoader for StaticThreadConfigLoader { + async fn load( + &self, + _context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + Ok(self.sources.clone()) + } +} + +/// Loader used when no external thread config source is configured. +#[derive(Clone, Debug, Default)] +pub struct NoopThreadConfigLoader; + +#[async_trait] +impl ThreadConfigLoader for NoopThreadConfigLoader { + async fn load( + &self, + _context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + Ok(Vec::new()) + } +} + +fn thread_config_source_to_layer( + source: ThreadConfigSource, +) -> Result, ThreadConfigLoadError> { + match source { + ThreadConfigSource::Session(config) => { + let config = session_thread_config_to_toml(config)?; + if is_empty_table(&config) { + Ok(None) + } else { + Ok(Some(ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + config, + ))) + } + } + // UserThreadConfig has no TOML-backed fields yet. When it grows one, + // fold it into the existing user layer instead of adding another + // ConfigLayerSource variant. + ThreadConfigSource::User(_config) => Ok(None), + } +} + +fn is_empty_table(config: &TomlValue) -> bool { + config.as_table().is_some_and(toml::map::Map::is_empty) +} + +fn session_thread_config_to_toml( + config: SessionThreadConfig, +) -> Result { + let mut table = toml::map::Map::new(); + + if let Some(model_provider) = config.model_provider { + table.insert( + "model_provider".to_string(), + TomlValue::String(model_provider), + ); + } + + if !config.model_providers.is_empty() { + let model_providers = TomlValue::try_from(config.model_providers).map_err(|err| { + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Parse, + /*status_code*/ None, + format!("failed to convert session model providers to config TOML: {err}"), + ) + })?; + table.insert("model_providers".to_string(), model_providers); + } + + if !config.features.is_empty() { + let features = config + .features + .into_iter() + .map(|(feature, enabled)| (feature, TomlValue::Boolean(enabled))) + .collect(); + table.insert("features".to_string(), TomlValue::Table(features)); + } + + Ok(TomlValue::Table(table)) +} + +#[cfg(test)] +mod tests { + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use pretty_assertions::assert_eq; + + use super::*; + + #[tokio::test] + async fn loader_returns_session_and_user_sources() { + let loader = StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ]); + + let sources = loader + .load(ThreadConfigContext { + thread_id: Some("thread-1".to_string()), + ..Default::default() + }) + .await + .expect("thread config loads"); + + assert_eq!( + sources, + vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ] + ); + } + + #[tokio::test] + async fn loader_translates_sources_to_config_layers() { + let loader = StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::User(UserThreadConfig::default()), + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ]); + let layers = loader + .load_config_layers(ThreadConfigContext { + cwd: Some( + AbsolutePathBuf::from_absolute_path_checked( + std::env::temp_dir().join("project"), + ) + .expect("absolute cwd"), + ), + ..Default::default() + }) + .await + .expect("thread config layers load"); + + assert_eq!( + layers, + vec![ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + model_provider = "local" + + [model_providers.local] + name = "local" + base_url = "http://127.0.0.1:8061/api/codex" + wire_api = "responses" + requires_openai_auth = false + supports_websockets = true + + [features] + plugins = false + } + .into() + )] + ); + } + + fn test_provider(name: &str) -> ModelProviderInfo { + ModelProviderInfo { + name: name.to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: true, + } + } +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7ee5b7df2f..9a6338875f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2110,6 +2110,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { &Vec::new(), overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; let cfg = @@ -2244,6 +2245,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 74421832d8..1dd46d94ce 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -21,6 +21,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; +use codex_config::ThreadConfigLoader; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::config_toml::RealtimeAudioConfig; @@ -90,6 +91,7 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use crate::config::permissions::compile_permission_profile; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; @@ -646,13 +648,14 @@ impl AuthManagerConfig for Config { } } -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] pub struct ConfigBuilder { codex_home: Option, cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, cloud_requirements: CloudRequirementsLoader, + thread_config_loader: Option>, fallback_cwd: Option, } @@ -682,6 +685,14 @@ impl ConfigBuilder { self } + pub fn thread_config_loader( + mut self, + thread_config_loader: Arc, + ) -> Self { + self.thread_config_loader = Some(thread_config_loader); + self + } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { self.fallback_cwd = fallback_cwd; self @@ -694,6 +705,7 @@ impl ConfigBuilder { harness_overrides, loader_overrides, cloud_requirements, + thread_config_loader, fallback_cwd, } = self; let codex_home = match codex_home { @@ -716,6 +728,9 @@ impl ConfigBuilder { &cli_overrides, loader_overrides, cloud_requirements, + thread_config_loader + .as_deref() + .unwrap_or(&codex_config::NoopThreadConfigLoader), ) .await?; let merged_toml = config_layer_stack.effective_config(); @@ -894,6 +909,7 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides( &cli_overrides, loader_overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1065,6 +1081,7 @@ pub async fn load_global_mcp_servers( &cli_overrides, LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; let merged_toml = config_layer_stack.effective_config(); diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 51288e60b9..c68091e8e3 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -431,6 +431,7 @@ impl ConfigService { &self.cli_overrides, self.loader_overrides.clone(), self.cloud_requirements.clone(), + &codex_config::NoopThreadConfigLoader, ) .await } diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index 44a514a10a..0d4e7d6b8d 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_core::config_loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -29,6 +29,9 @@ Precedence is **top overrides bottom**: 3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) 4. **User** config (`config.toml`) +Thread config entries supplied by `thread_config_loader` are inserted according +to their translated `ConfigLayerSource` precedence. + Layers with a `disabled_reason` are still surfaced for UI, but are ignored when computing the effective config and origins metadata. This is what `ConfigLayerStack::effective_config()` implements. @@ -41,6 +44,7 @@ Most callers want the effective config plus metadata: use codex_core::config_loader::{ CloudRequirementsLoader, LoaderOverrides, load_config_layers_state, }; +use codex_config::NoopThreadConfigLoader; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value as TomlValue; @@ -54,6 +58,7 @@ let layers = load_config_layers_state( &cli_overrides, LoaderOverrides::default(), CloudRequirementsLoader::default(), + &NoopThreadConfigLoader, ).await?; let effective = layers.effective_config(); diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 436759c72e..c72cb4cff4 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -9,6 +9,8 @@ use crate::config_loader::layer_io::LoadedConfigLayers; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigRequirementsWithSources; +use codex_config::ThreadConfigContext; +use codex_config::ThreadConfigLoader; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_exec_server::ExecutorFileSystem; @@ -127,6 +129,7 @@ pub async fn load_config_layers_state( cli_overrides: &[(String, TomlValue)], overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, + thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = @@ -161,6 +164,15 @@ pub async fn load_config_layers_state( ) .await?; + let thread_config_context = ThreadConfigContext { + thread_id: None, + cwd: cwd.clone(), + }; + let thread_config_layers = thread_config_loader + .load_config_layers(thread_config_context) + .await + .map_err(io::Error::other)?; + let mut layers = Vec::::new(); let cli_overrides_layer = if cli_overrides.is_empty() { @@ -283,6 +295,10 @@ pub async fn load_config_layers_state( )); } + for thread_config_layer in thread_config_layers { + insert_layer_by_precedence(&mut layers, thread_config_layer); + } + // Make a best-effort to support the legacy `managed_config.toml` as a // config layer on top of everything else. For fields in // `managed_config.toml` that do not have an equivalent in @@ -332,6 +348,16 @@ pub async fn load_config_layers_state( .with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules)) } +fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigLayerEntry) { + match layers + .iter() + .position(|existing| existing.name.precedence() > layer.name.precedence()) + { + Some(index) => layers.insert(index, layer), + None => layers.push(layer), + } +} + /// Attempts to load a config.toml file from `config_toml`. /// - If the file exists and is valid TOML, passes the parsed `toml::Value` to /// `create_entry` and returns the resulting layer entry. diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index edc642ec39..17f54be060 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -15,6 +15,9 @@ use crate::config_loader::RequirementSource; use crate::config_loader::load_requirements_toml; use crate::config_loader::version_for_toml; use codex_config::CONFIG_TOML_FILE; +use codex_config::SessionThreadConfig; +use codex_config::StaticThreadConfigLoader; +use codex_config::ThreadConfigSource; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_exec_server::LOCAL_FS; @@ -100,6 +103,7 @@ async fn returns_config_error_for_invalid_user_config_toml() { &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await .expect_err("expected error"); @@ -131,6 +135,7 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { ..Default::default() }, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -161,6 +166,7 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: ..Default::default() }, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -185,6 +191,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() { &[] as &[(String, TomlValue)], overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await .expect_err("expected error"); @@ -270,6 +277,7 @@ extra = true &[] as &[(String, TomlValue)], overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await .expect("load config"); @@ -303,6 +311,7 @@ async fn returns_empty_when_all_layers_missing() { &[] as &[(String, TomlValue)], overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await .expect("load layers"); @@ -354,6 +363,56 @@ async fn returns_empty_when_all_layers_missing() { } } +#[tokio::test] +async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { + let tmp = tempdir()?; + let cwd_dir = tmp.path().join("project"); + tokio::fs::create_dir_all(&cwd_dir).await?; + let cwd = AbsolutePathBuf::from_absolute_path(&cwd_dir)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[("features.plugins".to_string(), TomlValue::Boolean(true))], + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + &StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig { + features: BTreeMap::from([("plugins".to_string(), false)]), + ..Default::default() + })]), + ) + .await?; + + let layer_sources = layers + .layers_high_to_low() + .into_iter() + .map(|layer| layer.name.clone()) + .collect::>(); + assert_eq!( + layer_sources, + vec![ + super::ConfigLayerSource::SessionFlags, + super::ConfigLayerSource::SessionFlags, + super::ConfigLayerSource::User { + file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), + }, + super::ConfigLayerSource::System { + file: super::system_config_toml_file()?, + }, + ] + ); + assert_eq!( + layers + .effective_config() + .get("features") + .and_then(TomlValue::as_table) + .and_then(|features| features.get("plugins")), + Some(&TomlValue::Boolean(false)) + ); + + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn managed_preferences_take_highest_precedence() { @@ -396,6 +455,7 @@ flag = false &[] as &[(String, TomlValue)], overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await .expect("load config"); @@ -498,6 +558,7 @@ allowed_sandbox_modes = ["read-only"] &[] as &[(String, TomlValue)], loader_overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -560,6 +621,7 @@ allowed_approval_policies = ["never"] &[] as &[(String, TomlValue)], loader_overrides, CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -713,6 +775,7 @@ allowed_approval_policies = ["on-request"] guardian_policy_config: None, })) }), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -931,6 +994,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> &[] as &[(String, TomlValue)], LoaderOverrides::default(), cloud_requirements, + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -974,6 +1038,7 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh "cloud requirements failed", )) }), + &codex_config::NoopThreadConfigLoader, ) .await .expect_err("cloud requirements failure should fail closed"); @@ -1021,6 +1086,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1166,6 +1232,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1206,6 +1273,7 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1263,6 +1331,7 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1334,6 +1403,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; let project_layers_untrusted: Vec<_> = layers_untrusted @@ -1373,6 +1443,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; let project_layers_unknown: Vec<_> = layers_unknown @@ -1439,6 +1510,7 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1592,6 +1664,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; let project_layers: Vec<_> = layers @@ -1660,6 +1733,7 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; let project_layers: Vec<_> = layers @@ -1720,6 +1794,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io &cli_overrides, LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1763,6 +1838,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index af4280bfb4..8829b45c3d 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -53,6 +53,7 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec, for empty_cli_overrides, LoaderOverrides::default(), CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, ) .await {