From f73a478c87a78a553f8009e6b26b44cc700c7f35 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 7 Oct 2026 23:07:18 +0000 Subject: [PATCH] Keep global app-server config independent of the launch directory (#51872) ## Why Global requests can inherit project settings from the app-server's launch directory. This can prevent marketplace removal or expose project-only feature settings, and config reloads can fail if that directory is deleted. ## What changed - Load configuration without project context when no working directory is supplied, using `CODEX_HOME` to materialize `Config.cwd`. - Use global configuration for marketplace mutations, marketplace upgrade reloads, and MCP requests without thread context. - Pass the launch directory explicitly during startup to preserve project-aware startup configuration. ## Testing Update regression tests to verify that marketplace removal ignores launch-project configuration and leaves the project config intact. Extend feature-list coverage to verify that global requests ignore project features, thread requests retain them, and global requests work after the launch directory is deleted on Unix. GitOrigin-RevId: b77063a7ea0b09fd0a79cfc4040db9f1d02606b3 --- codex-rs/app-server/src/config_manager.rs | 25 +++++++++++-- codex-rs/app-server/src/lib.rs | 8 ++-- codex-rs/app-server/src/message_processor.rs | 7 +--- .../app-server/src/plugin_config_reload.rs | 10 +++++ .../marketplace_processor.rs | 26 ++++--------- .../src/request_processors/mcp_processor.rs | 13 +++---- .../suite/v2/experimental_feature_list.rs | 36 +++++++++++++++++- .../tests/suite/v2/marketplace_remove.rs | 37 ++++++------------- codex-rs/core/src/config/mod.rs | 10 ++++- 9 files changed, 105 insertions(+), 67 deletions(-) diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 21d1780910e9..2f826de9ff91 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -192,11 +192,14 @@ impl ConfigManager { &self, fallback_cwd: Option, ) -> std::io::Result { + let Some(fallback_cwd) = fallback_cwd else { + return self.load_non_project_config().await; + }; self.load_with_cli_overrides( &self.current_cli_overrides(), /*request_overrides*/ None, ConfigOverrides::default(), - fallback_cwd, + Some(fallback_cwd), ) .await } @@ -204,9 +207,23 @@ impl ConfigManager { /// Loads system, user, and runtime settings without discovering a project /// from the app-server process's working directory. pub(crate) async fn load_non_project_config(&self) -> std::io::Result { - let mut manager = self.clone(); - manager.loader_overrides.ignore_project_config = true; - manager.load_latest_config(/*fallback_cwd*/ None).await + let policy_load = self.refresh_application_network_policy().await?; + let mut config = ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.current_cli_overrides()) + .loader_overrides(self.loader_overrides.clone()) + .strict_config(self.strict_config) + .fallback_cwd(Some(self.codex_home.clone())) + .cloud_config_bundle(policy_load.cloud_config.clone()) + .thread_config_loader(Arc::clone(&self.thread_config_loader)) + .without_project_context() + .build() + .await?; + self.check_application_policy_load(&policy_load)?; + self.apply_network_policy(&mut config); + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) } pub(crate) async fn load_latest_config_with_session_layers( diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 896aa6623b69..e170dd222e2d 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -529,6 +529,7 @@ pub async fn run_main_with_transport_options( ) })?; let codex_home = find_codex_home()?; + let startup_cwd = std::env::current_dir().ok(); let local_runtime_paths = ExecServerRuntimeOptions::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), @@ -544,7 +545,7 @@ pub async fn run_main_with_transport_options( Arc::new(NoopThreadConfigLoader), ); let bootstrap_config = config_manager - .load_startup_config(/*fallback_cwd*/ None) + .load_startup_config(startup_cwd.clone()) .await?; let bootstrap_auth = AuthManager::shared_from_config(&bootstrap_config, /*enable_codex_api_key_env*/ false) @@ -557,10 +558,7 @@ pub async fn run_main_with_transport_options( ); let mut config_warnings = Vec::new(); let mut plugin_startup_config = PluginStartupConfig::Current; - let config = match config_manager - .load_latest_config(/*fallback_cwd*/ None) - .await - { + let config = match config_manager.load_latest_config(startup_cwd).await { Ok(config) => config, Err(err) if is_unsupported_untrusted_approval_policy_error(&err) => { return Err(err); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6b705c55565e..e5434177b27d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -465,11 +465,8 @@ impl MessageProcessor { rpc_transport, Arc::clone(&user_verification), ); - let marketplace_processor = MarketplaceRequestProcessor::new( - Arc::clone(&config), - config_manager.clone(), - Arc::clone(&thread_manager), - ); + let marketplace_processor = + MarketplaceRequestProcessor::new(config_manager.clone(), Arc::clone(&thread_manager)); let mcp_processor = McpRequestProcessor::new( auth_manager.clone(), Arc::clone(&thread_manager), diff --git a/codex-rs/app-server/src/plugin_config_reload.rs b/codex-rs/app-server/src/plugin_config_reload.rs index 13842d1ba0b6..72b301cf6088 100644 --- a/codex-rs/app-server/src/plugin_config_reload.rs +++ b/codex-rs/app-server/src/plugin_config_reload.rs @@ -17,6 +17,16 @@ pub(crate) fn for_cwd(manager: ConfigManager, cwd: AbsolutePathBuf) -> ConfigLay Arc::new(move || runtime.block_on(manager.load_config_layers_for_cwd(cwd.clone()))) } +/// Reloads config without deriving project context from a process cwd. +pub(crate) fn global(manager: ConfigManager) -> ConfigLayerReload { + let runtime = tokio::runtime::Handle::current(); + Arc::new(move || { + runtime + .block_on(manager.load_non_project_config()) + .map(|config| config.config_layer_stack) + }) +} + pub(crate) fn defaults(manager: ConfigManager) -> ConfigLayerReload { let runtime = tokio::runtime::Handle::current(); Arc::new(move || { diff --git a/codex-rs/app-server/src/request_processors/marketplace_processor.rs b/codex-rs/app-server/src/request_processors/marketplace_processor.rs index 31ff3c8229b3..5d961528cffd 100644 --- a/codex-rs/app-server/src/request_processors/marketplace_processor.rs +++ b/codex-rs/app-server/src/request_processors/marketplace_processor.rs @@ -3,19 +3,13 @@ use crate::plugin_config_reload; #[derive(Clone)] pub(crate) struct MarketplaceRequestProcessor { - config: Arc, config_manager: ConfigManager, thread_manager: Arc, } impl MarketplaceRequestProcessor { - pub(crate) fn new( - config: Arc, - config_manager: ConfigManager, - thread_manager: Arc, - ) -> Self { + pub(crate) fn new(config_manager: ConfigManager, thread_manager: Arc) -> Self { Self { - config, config_manager, thread_manager, } @@ -52,7 +46,7 @@ impl MarketplaceRequestProcessor { &self, params: MarketplaceRemoveParams, ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let config = self.load_global_config().await?; remove_marketplace( config.codex_home.to_path_buf(), config.config_layer_stack, @@ -75,12 +69,11 @@ impl MarketplaceRequestProcessor { &self, params: MarketplaceUpgradeParams, ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let config = self.load_global_config().await?; let plugins_manager = self.thread_manager.plugins_manager(); let MarketplaceUpgradeParams { marketplace_name } = params; let plugins_input = config.plugins_config_input(); - let reload_config = - plugin_config_reload::for_cwd(self.config_manager.clone(), config.cwd.clone()); + let reload_config = plugin_config_reload::global(self.config_manager.clone()); let outcome = tokio::task::spawn_blocking(move || { plugins_manager.upgrade_configured_marketplaces_for_config( @@ -118,9 +111,9 @@ impl MarketplaceRequestProcessor { &self, params: MarketplaceAddParams, ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let config = self.load_global_config().await?; add_marketplace_to_codex_home( - self.config.codex_home.to_path_buf(), + config.codex_home.to_path_buf(), config.config_layer_stack.requirements().clone(), MarketplaceAddRequest { source: params.source, @@ -140,12 +133,9 @@ impl MarketplaceRequestProcessor { }) } - async fn load_latest_config( - &self, - fallback_cwd: Option, - ) -> Result { + async fn load_global_config(&self) -> Result { self.config_manager - .load_latest_config(fallback_cwd) + .load_non_project_config() .await .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 16adf8eb2133..e65e6d744e3d 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -106,12 +106,9 @@ impl McpRequestProcessor { Ok(McpServerRefreshResponse {}) } - async fn load_latest_config( - &self, - fallback_cwd: Option, - ) -> Result { + async fn load_global_config(&self) -> Result { self.config_manager - .load_latest_config(fallback_cwd) + .load_non_project_config() .await .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } @@ -152,7 +149,7 @@ impl McpRequestProcessor { ((*config).clone(), runtime_context) } None => { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let config = self.load_global_config().await?; let mcp_config = self .thread_manager .mcp_manager() @@ -421,7 +418,7 @@ impl McpRequestProcessor { .await .map_err(|err| internal_error(format!("failed to reload config: {err}")))? } - None => self.load_latest_config(/*fallback_cwd*/ None).await?, + None => self.load_global_config().await?, }; let mcp_manager = self.thread_manager.mcp_manager(); let auth = self.auth_manager.auth().await; @@ -622,7 +619,7 @@ impl McpRequestProcessor { return Err(invalid_request("originCallId requires threadId")); } - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let config = self.load_global_config().await?; let mcp_manager = self.thread_manager.mcp_manager(); let mcp_config = mcp_manager.runtime_config(&config).await; let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache(); diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 14f6122fde53..0126c717aae5 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -145,12 +145,16 @@ async fn experimental_feature_list_resolves_thread_project_config() -> Result<() let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let workspace = TempDir::new()?; + let codex_home_key = codex_home.path().to_string_lossy().replace('\\', "\\\\"); let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); MockResponsesConfig::new(&server.uri()) .with_extra_config(&format!( - "[projects.\"{workspace_key}\"]\ntrust_level = \"trusted\"" + "[projects.\"{codex_home_key}\"]\ntrust_level = \"trusted\"\n\ + [projects.\"{workspace_key}\"]\ntrust_level = \"trusted\"" )) .write(codex_home.path())?; + #[cfg(unix)] + let config_toml = std::fs::read(codex_home.path().join("config.toml"))?; let project_config_dir = workspace.path().join(".codex"); std::fs::create_dir_all(&project_config_dir)?; std::fs::write( @@ -159,6 +163,12 @@ async fn experimental_feature_list_resolves_thread_project_config() -> Result<() memories = true "#, )?; + let launch_project_config_dir = codex_home.path().join(".codex"); + std::fs::create_dir(&launch_project_config_dir)?; + std::fs::copy( + project_config_dir.join("config.toml"), + launch_project_config_dir.join("config.toml"), + )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -166,6 +176,30 @@ memories = true .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; + #[cfg(unix)] + { + let deleted_launch_dir = workspace.path().join("deleted-launch"); + std::fs::rename(codex_home.path(), &deleted_launch_dir)?; + std::fs::create_dir(codex_home.path())?; + std::fs::write(codex_home.path().join("config.toml"), config_toml)?; + std::fs::create_dir(&launch_project_config_dir)?; + std::fs::copy( + project_config_dir.join("config.toml"), + launch_project_config_dir.join("config.toml"), + )?; + std::fs::remove_dir_all(deleted_launch_dir)?; + } + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) + .await?; + let global = read_response::(&mut mcp, request_id).await?; + let global_memories = global + .data + .iter() + .find(|feature| feature.name == "memories") + .expect("memories feature should be present"); + assert!(!global_memories.enabled); + let thread_start_id = mcp .send_thread_start_request_with_auto_env(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs index c1e6a4e253d6..158d08996270 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs @@ -123,15 +123,11 @@ async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { Ok(()) } -#[test_case(false; "project only")] -#[test_case(true; "project and user")] #[tokio::test] -async fn marketplace_remove_preserves_project_marketplace(user_entry: bool) -> Result<()> { +async fn marketplace_remove_ignores_startup_project_config() -> Result<()> { let codex_home = TempDir::new()?; - if user_entry { - record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; - } - // TestAppServer starts in CODEX_HOME, so make it a trusted project as well. + // TestAppServer starts in CODEX_HOME, so put project config there to verify + // that the global marketplace RPC does not use it. std::fs::create_dir_all(codex_home.path().join(".git"))?; std::fs::create_dir_all(codex_home.path().join(".codex"))?; let project_config_path = codex_home.path().join(".codex/config.toml"); @@ -141,35 +137,26 @@ async fn marketplace_remove_preserves_project_marketplace(user_entry: bool) -> R write_installed_marketplace(codex_home.path(), "debug")?; let snapshot_path = marketplace_install_root(codex_home.path()).join("debug/.agents/plugins/marketplace.json"); - let user_config_path = codex_home.path().join("config.toml"); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() .await?; - let user_config = std::fs::read_to_string(&user_config_path)?; - let request_id = mcp - .send_marketplace_remove_request(MarketplaceRemoveParams { - marketplace_name: "debug".to_string(), + let response: MarketplaceRemoveResponse = mcp + .request(|request_id| ClientRequest::MarketplaceRemove { + request_id, + params: MarketplaceRemoveParams { + marketplace_name: "debug".to_string(), + }, }) .await?; - let err = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), - ) - .await??; - assert_eq!(err.error.code, -32600); - assert!( - err.error - .message - .starts_with("marketplace `debug` is configured in project (") - ); - assert_eq!(std::fs::read_to_string(user_config_path)?, user_config); + assert_eq!(response.marketplace_name, "debug"); + assert!(response.installed_root.is_some()); assert_eq!( std::fs::read_to_string(project_config_path)?, project_config ); - assert_eq!(std::fs::read_to_string(snapshot_path)?, "{}"); + assert!(!snapshot_path.exists()); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c264a0f0c568..062e7db10b11 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1457,6 +1457,7 @@ pub struct ConfigBuilder { cloud_config_bundle: CloudConfigBundleLoader, thread_config_loader: Option>, fallback_cwd: Option, + without_project_context: bool, } impl ConfigBuilder { @@ -1503,6 +1504,12 @@ impl ConfigBuilder { self } + /// Materializes `Config.cwd` without using it as config-layer context. + pub fn without_project_context(mut self) -> Self { + self.without_project_context = true; + self + } + pub async fn build(self) -> std::io::Result { // Keep the large config-loading future off small runtime thread stacks. Box::pin(self.build_inner()).await @@ -1518,6 +1525,7 @@ impl ConfigBuilder { cloud_config_bundle, thread_config_loader, fallback_cwd, + without_project_context, } = self; let codex_home = match codex_home { Some(codex_home) => AbsolutePathBuf::from_absolute_path(codex_home)?, @@ -1535,7 +1543,7 @@ impl ConfigBuilder { let config_layer_stack = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, - Some(cwd), + (!without_project_context).then_some(cwd), &cli_overrides, ConfigLoadOptions { loader_overrides,