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
parent 513dc28717
commit 7b994100b3
21 changed files with 553 additions and 2 deletions

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 }

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;

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,
}
}
}