Files
codex/codex-rs/config/src/thread_config.rs
T
alexsong-oai be7cd6fd9e Add a credential file override for GCP provider authentication (#57188)
## What changed

Support `model_providers.gcp.credential_file` as an absolute path to a Google Application Default Credentials (ADC) file. Pass it as `GOOGLE_APPLICATION_CREDENTIALS` in the provider auth command's environment, retaining the override for token refreshes without changing the parent process environment.

Validate the path before merging GCP defaults and preserve the setting in thread configuration serialization.

## Testing

Add coverage for invalid paths, configuration round trips, separate provider token caches, and credential overrides retained after a `401` refresh. Include a Windows regression test for credential paths containing command metacharacters.

GitOrigin-RevId: d9a1d4b3775a9a4c4325dddaa153d92a2d1430a0
2026-10-26 18:33:05 +00:00

327 lines
10 KiB
Rust

use std::collections::BTreeMap;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use crate::ConfigLayerSource;
use codex_model_provider_info::ModelProviderInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use thiserror::Error;
use toml::Value as TomlValue;
use crate::ConfigLayerEntry;
mod remote;
pub use remote::RemoteThreadConfigLoader;
/// 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
}
}
/// 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.
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.
fn load(
&self,
context: ThreadConfigContext,
) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>>;
fn load_config_layers(
&self,
context: ThreadConfigContext,
) -> ThreadConfigLoaderFuture<'_, Vec<ConfigLayerEntry>> {
Box::pin(async move {
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())
})
}
}
pub type ThreadConfigLoaderFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, ThreadConfigLoadError>> + Send + 'a>>;
/// 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 }
}
}
impl ThreadConfigLoader for StaticThreadConfigLoader {
fn load(
&self,
_context: ThreadConfigContext,
) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>> {
Box::pin(async { Ok(self.sources.clone()) })
}
}
/// Loader used when no external thread config source is configured.
#[derive(Clone, Debug, Default)]
pub struct NoopThreadConfigLoader;
impl ThreadConfigLoader for NoopThreadConfigLoader {
fn load(
&self,
_context: ThreadConfigContext,
) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>> {
Box::pin(async { 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
supports_standalone_web_search = 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()),
model_catalog_url: None,
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
auth: None,
gateway_oauth: None,
aws: 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,
supports_standalone_web_search: true,
capabilities: None,
project_id: None,
location: None,
credential_file: None,
include_internal_metadata: false,
}
}
}