Add session config loader interface (#18208)

## Why

Cloud-hosted sessions need a way for the service that starts or manages
a thread to provide session-owned config without treating all config as
if it came from the same user/project/workspace TOML stack.

The important boundary is ownership: some values should be controlled by
the session/orchestrator, some by the authenticated user, and later some
may come from the executor. The earlier broad config-store shape made
that boundary too fuzzy and overlapped heavily with the existing
filesystem-backed config loader. This PR starts with the smaller piece
we need now: a typed session config loader that can feed the existing
config layer stack while preserving the normal precedence and merge
behavior.

## What Changed

- Added `ThreadConfigLoader` and related typed payloads in
`codex-config`.
- `SessionThreadConfig` currently supports `model_provider`,
`model_providers`, and feature flags.
- `UserThreadConfig` is present as an ownership boundary, but does not
yet add TOML-backed fields.
- `NoopThreadConfigLoader` preserves existing behavior when no external
loader is configured.
  - `StaticThreadConfigLoader` supports tests and simple callers.

- Taught thread config sources to produce ordinary `ConfigLayerEntry`
values so the existing `ConfigLayerStack` remains the place where
precedence and merging happen.

- Wired the loader through `ConfigBuilder`, the config loader, and
app-server startup paths so app-server can provide session-owned config
before deriving a thread config.

- Added coverage for:
  - translating typed thread config into config layers,
- inserting thread config layers into the stack at the right precedence,
- applying session-provided model provider and feature settings when
app-server derives config from thread params.

## Follow-Ups

This intentionally stops short of adding the remote/service transport.
The next pieces are expected to be:

1. Define the proto/API shape for this interface.
2. Add a client implementation that can source session config from the
service side.

## Verification

- Added unit coverage in `codex-config` for the loader and layer
conversion.
- Added `codex-core` config loader coverage for thread config layer
precedence.
- Added app-server coverage that verifies session thread config wins
over request-provided config for model provider and feature settings.
This commit is contained in:
Rasmus Rygaard
2026-04-20 16:05:49 -07:00
committed by GitHub
Unverified
parent 513dc28717
commit 7b994100b3
21 changed files with 553 additions and 2 deletions
+3
View File
@@ -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",
+1
View File
@@ -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 }
+2
View File
@@ -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,
+1
View File
@@ -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 }
@@ -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<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
thread_state_manager: ThreadStateManager,
@@ -639,6 +641,7 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
pub(crate) runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
pub(crate) thread_config_loader: Arc<dyn ThreadConfigLoader>,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
}
@@ -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<String, bool>,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
request_id: ConnectionRequestId,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
@@ -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<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
cloud_requirements: &CloudRequirementsLoader,
codex_home: &Path,
runtime_feature_enablement: &BTreeMap<String, bool>,
@@ -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 {
+5
View File
@@ -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<dyn ThreadConfigLoader>,
/// 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)),
+3
View File
@@ -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,
@@ -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<dyn ThreadConfigLoader>,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
@@ -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,
});
@@ -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(),
@@ -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)),
+1
View File
@@ -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 }
+10
View File
@@ -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;
+313
View File
@@ -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<String>,
pub cwd: Option<AbsolutePathBuf>,
}
/// Config values owned by the service that starts or manages the session.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SessionThreadConfig {
pub model_provider: Option<String>,
pub model_providers: HashMap<String, ModelProviderInfo>,
pub features: BTreeMap<String, bool>,
}
/// 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<u16>,
}
impl ThreadConfigLoadError {
pub fn new(
code: ThreadConfigLoadErrorCode,
status_code: Option<u16>,
message: impl Into<String>,
) -> Self {
Self {
code,
message: message.into(),
status_code,
}
}
pub fn code(&self) -> ThreadConfigLoadErrorCode {
self.code
}
pub fn status_code(&self) -> Option<u16> {
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<Vec<ThreadConfigSource>, ThreadConfigLoadError>;
async fn load_config_layers(
&self,
context: ThreadConfigContext,
) -> Result<Vec<ConfigLayerEntry>, ThreadConfigLoadError> {
let sources = self.load(context).await?;
sources
.into_iter()
.map(thread_config_source_to_layer)
.collect::<Result<Vec<_>, _>>()
.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<ThreadConfigSource>,
}
impl StaticThreadConfigLoader {
pub fn new(sources: Vec<ThreadConfigSource>) -> Self {
Self { sources }
}
}
#[async_trait]
impl ThreadConfigLoader for StaticThreadConfigLoader {
async fn load(
&self,
_context: ThreadConfigContext,
) -> Result<Vec<ThreadConfigSource>, 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<Vec<ThreadConfigSource>, ThreadConfigLoadError> {
Ok(Vec::new())
}
}
fn thread_config_source_to_layer(
source: ThreadConfigSource,
) -> Result<Option<ConfigLayerEntry>, 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<TomlValue, ThreadConfigLoadError> {
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,
}
}
}
+2
View File
@@ -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?;
+18 -1
View File
@@ -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<PathBuf>,
cli_overrides: Option<Vec<(String, TomlValue)>>,
harness_overrides: Option<ConfigOverrides>,
loader_overrides: Option<LoaderOverrides>,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: Option<Arc<dyn ThreadConfigLoader>>,
fallback_cwd: Option<PathBuf>,
}
@@ -682,6 +685,14 @@ impl ConfigBuilder {
self
}
pub fn thread_config_loader(
mut self,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
) -> Self {
self.thread_config_loader = Some(thread_config_loader);
self
}
pub fn fallback_cwd(mut self, fallback_cwd: Option<PathBuf>) -> 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();
+1
View File
@@ -431,6 +431,7 @@ impl ConfigService {
&self.cli_overrides,
self.loader_overrides.clone(),
self.cloud_requirements.clone(),
&codex_config::NoopThreadConfigLoader,
)
.await
}
+6 -1
View File
@@ -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<String, ConfigLayerMetadata>`
@@ -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();
+26
View File
@@ -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<ConfigLayerStack> {
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::<ConfigLayerEntry>::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<ConfigLayerEntry>, 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.
+76
View File
@@ -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::<Vec<_>>();
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?;
@@ -53,6 +53,7 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
&cli_overrides,
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await
.context("failed to load Codex config")?;
+1
View File
@@ -545,6 +545,7 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec<PathBuf>, for
empty_cli_overrides,
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await
{