diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 1c3de1208b15..483f234d9009 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -42,6 +42,8 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; use codex_config::NoopThreadConfigLoader; +use codex_config::RemoteThreadConfigLoader; +use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -357,6 +359,13 @@ pub struct InProcessClientStartArgs { pub channel_capacity: usize, } +fn configured_thread_config_loader(config: &Config) -> Arc { + match config.experimental_thread_config_endpoint.as_deref() { + Some(endpoint) => Arc::new(RemoteThreadConfigLoader::new(endpoint)), + None => Arc::new(NoopThreadConfigLoader), + } +} + impl InProcessClientStartArgs { /// Builds initialize params from caller-provided metadata. pub fn initialize_params(&self) -> InitializeParams { @@ -381,13 +390,14 @@ impl InProcessClientStartArgs { fn into_runtime_start_args(self) -> InProcessStartArgs { let initialize = self.initialize_params(); + let thread_config_loader = configured_thread_config_loader(&self.config); InProcessStartArgs { arg0_paths: self.arg0_paths, config: self.config, cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, cloud_requirements: self.cloud_requirements, - thread_config_loader: Arc::new(NoopThreadConfigLoader), + thread_config_loader, feedback: self.feedback, log_db: self.log_db, environment_manager: self.environment_manager, @@ -2013,6 +2023,42 @@ mod tests { ); } + #[tokio::test] + async fn runtime_start_args_use_remote_thread_config_loader_when_configured() { + let mut config = build_test_config().await; + config.experimental_thread_config_endpoint = Some("not-a-valid-endpoint".to_string()); + + let runtime_args = InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + log_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + } + .into_runtime_start_args(); + + let err = runtime_args + .thread_config_loader + .load(Default::default()) + .await + .expect_err("configured remote loader should try to connect"); + assert_eq!( + err.code(), + codex_config::ThreadConfigLoadErrorCode::RequestFailed + ); + } + #[tokio::test] async fn shutdown_completes_promptly_without_retained_managers() { let client = start_test_client(SessionSource::Cli).await; diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 69b819c1526b..43dd19004504 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -32,7 +32,7 @@ pub(crate) struct ConfigManager { loader_overrides: LoaderOverrides, cloud_requirements: Arc>, arg0_paths: Arg0DispatchPaths, - thread_config_loader: Arc, + thread_config_loader: Arc>>, host_name: Option, } @@ -73,7 +73,7 @@ impl ConfigManager { loader_overrides, cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), arg0_paths, - thread_config_loader, + thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), host_name, } } @@ -120,6 +120,24 @@ impl ConfigManager { } } + pub(crate) fn replace_thread_config_loader( + &self, + thread_config_loader: Arc, + ) { + if let Ok(mut guard) = self.thread_config_loader.write() { + *guard = thread_config_loader; + } else { + warn!("failed to update thread config loader"); + } + } + + fn current_thread_config_loader(&self) -> Arc { + self.thread_config_loader + .read() + .map(|guard| Arc::clone(&*guard)) + .unwrap_or_else(|_| Arc::new(codex_config::NoopThreadConfigLoader)) + } + pub(crate) async fn sync_default_client_residency_requirement(&self) { match self.load_latest_config(/*fallback_cwd*/ None).await { Ok(config) => { @@ -210,7 +228,7 @@ impl ConfigManager { .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) .cloud_requirements(self.current_cloud_requirements()) - .thread_config_loader(Arc::clone(&self.thread_config_loader)) + .thread_config_loader(self.current_thread_config_loader()) .host_name(self.host_name.clone()) .build() .await?; @@ -230,6 +248,7 @@ impl ConfigManager { &self, cwd: Option, ) -> std::io::Result { + let thread_config_loader = self.current_thread_config_loader(); load_config_layers_state( LOCAL_FS.as_ref(), &self.codex_home, @@ -237,7 +256,7 @@ impl ConfigManager { &self.current_cli_overrides(), self.loader_overrides.clone(), self.current_cloud_requirements(), - self.thread_config_loader.as_ref(), + thread_config_loader.as_ref(), self.host_name.as_deref(), ) .await diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a2f35305ae75..d79514f071cf 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_config::NoopThreadConfigLoader; +use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::config_loader::ConfigLayerStackOrdering; @@ -107,6 +108,13 @@ enum LogFormat { type StderrLogLayer = Box + Send + Sync + 'static>; +fn configured_thread_config_loader(config: &Config) -> Arc { + match config.experimental_thread_config_endpoint.as_deref() { + Some(endpoint) => Arc::new(RemoteThreadConfigLoader::new(endpoint)), + None => Arc::new(NoopThreadConfigLoader), + } +} + /// Control-plane messages from the processor/transport side to the outbound router task. /// /// `run_main_with_transport` now uses two loops/tasks: @@ -382,14 +390,13 @@ pub async fn run_main_with_transport( ) })?; let codex_home = find_codex_home()?; - let thread_config_loader: Arc = Arc::new(NoopThreadConfigLoader); let config_manager = ConfigManager::new( codex_home.to_path_buf(), cli_kv_overrides.clone(), loader_overrides, Default::default(), arg0_paths.clone(), - thread_config_loader.clone(), + Arc::new(NoopThreadConfigLoader), ); match config_manager .load_latest_config(/*fallback_cwd*/ None) @@ -413,6 +420,9 @@ pub async fn run_main_with_transport( } } + let discovered_thread_config_loader = configured_thread_config_loader(&config); + config_manager + .replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader)); let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); config_manager.replace_cloud_requirements_loader(auth_manager, config.chatgpt_base_url); diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 543cb59ef3bb..8d6321e4120b 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -313,6 +313,10 @@ pub struct ConfigToml { /// Experimental / do not use. When set, app-server uses a remote thread /// store at this endpoint instead of the local filesystem/SQLite store. pub experimental_thread_store_endpoint: Option, + + /// Experimental / do not use. When set, app-server fetches thread-scoped + /// config from a remote service at this endpoint. + pub experimental_thread_config_endpoint: Option, pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1223b45a6212..0673619971f9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2484,6 +2484,10 @@ "description": "Experimental / do not use. Replaces the synthesized realtime startup context appended to websocket session instructions. An empty string disables startup context injection entirely.", "type": "string" }, + "experimental_thread_config_endpoint": { + "description": "Experimental / do not use. When set, app-server fetches thread-scoped config from a remote service at this endpoint.", + "type": "string" + }, "experimental_thread_store_endpoint": { "description": "Experimental / do not use. When set, app-server uses a remote thread store at this endpoint instead of the local filesystem/SQLite store.", "type": "string" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 3dddde05d101..d46d1a316d93 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5237,6 +5237,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5433,6 +5434,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5583,6 +5585,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5718,6 +5721,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -7240,6 +7244,35 @@ experimental_realtime_start_instructions = "start instructions from config" Ok(()) } +#[tokio::test] +async fn experimental_thread_config_endpoint_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_thread_config_endpoint = "http://127.0.0.1:8061" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_thread_config_endpoint.as_deref(), + Some("http://127.0.0.1:8061") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.experimental_thread_config_endpoint.as_deref(), + Some("http://127.0.0.1:8061") + ); + Ok(()) +} + #[tokio::test] async fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d30ed79a6ec0..cb6f78839911 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -543,6 +543,10 @@ pub struct Config { /// Experimental / do not use. When set, app-server uses a remote thread /// store at this endpoint instead of the local filesystem/SQLite store. pub experimental_thread_store_endpoint: Option, + + /// Experimental / do not use. When set, app-server fetches thread-scoped + /// config from a remote service at this endpoint. + pub experimental_thread_config_endpoint: Option, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -2419,6 +2423,7 @@ impl Config { experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions, experimental_thread_store_endpoint: cfg.experimental_thread_store_endpoint, + experimental_thread_config_endpoint: cfg.experimental_thread_config_endpoint, forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag,