diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7cc975f198..39e3809c3e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -295,7 +295,7 @@ Example with notification opt-out: - `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. -- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth. For remote installs, clients may include an optional `installAttemptId`; app-server forwards it unchanged as `install_attempt_id` in the backend POST body, while omission preserves the legacy empty-body request (**under development; do not call from production clients yet**). +- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth. Local marketplace installation also reloads user configuration for loaded threads before invalidating their MCP runtimes and returning; this applies pending user-config changes, including hook settings. Reload failures are logged without undoing installation, and MCP startup can finish afterward. For remote installs, clients may include an optional `installAttemptId`; app-server forwards it unchanged as `install_attempt_id` in the backend POST body, while omission preserves the legacy empty-body request (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, optionally pass `clientRegistration` (`auto`, `cimd`, or `dcr`) to override client registration for this login only, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes. Omitting `clientRegistration` automatically discovers the authorization server's supported registration methods; the override is never persisted in server configuration. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 7eda9def9f..9dab485802 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -163,12 +163,12 @@ impl ConfigRequestProcessor { | "personality" ) }); - let reload_user_config = params.reload_user_config; + let should_reload = params.reload_user_config; let response = self.batch_write_inner(params).await?; if !session_defaults_only { self.handle_config_mutation().await; - if reload_user_config { - self.reload_user_config().await; + if should_reload { + reload_user_config(&self.config_manager, &self.thread_manager).await; } } Ok(ClientResponsePayload::ConfigBatchWrite(response)) @@ -183,7 +183,7 @@ impl ConfigRequestProcessor { .handle_config_mutation_result(self.set_experimental_feature_enablement(params).await) .await?; if !response.enablement.is_empty() { - self.reload_user_config().await; + reload_user_config(&self.config_manager, &self.thread_manager).await; } self.outgoing .send_response_as( @@ -321,38 +321,6 @@ impl ConfigRequestProcessor { Ok(ExperimentalFeatureEnablementSetResponse { enablement }) } - async fn reload_user_config(&self) { - match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(_) => {} - Err(err) => { - tracing::warn!( - "failed to rebuild user config for runtime refresh: {}", - err.message - ); - return; - } - }; - let thread_ids = self.thread_manager.list_thread_ids().await; - for thread_id in thread_ids { - let Ok(thread) = self.thread_manager.get_thread(thread_id).await else { - continue; - }; - let current_config = thread.config().await; - let next_config = match self - .config_manager - .load_latest_config_for_thread(current_config.as_ref()) - .await - { - Ok(config) => config, - Err(err) => { - tracing::warn!(%thread_id, %err, "failed to reload thread configuration"); - continue; - } - }; - thread.refresh_runtime_config(next_config).await; - } - } - async fn emit_plugin_toggle_events( &self, pending_changes: std::collections::BTreeMap, @@ -374,6 +342,38 @@ impl ConfigRequestProcessor { } } +pub(super) async fn reload_user_config( + config_manager: &ConfigManager, + thread_manager: &ThreadManager, +) { + if let Err(err) = config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + { + tracing::warn!("failed to rebuild user config for runtime refresh: {err}"); + return; + } + let thread_ids = thread_manager.list_thread_ids().await; + for thread_id in thread_ids { + let Ok(thread) = thread_manager.get_thread(thread_id).await else { + continue; + }; + let current_config = thread.config().await; + let next_config = match config_manager + .load_latest_config_for_thread(current_config.as_ref()) + .await + { + Ok(config) => config, + Err(err) => { + tracing::warn!(%thread_id, %err, "failed to reload thread configuration"); + continue; + } + }; + // Keep runtime refresh state off the request dispatcher's stack. + Box::pin(thread.refresh_runtime_config(next_config)).await; + } +} + fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { let windows_sandbox_private_desktop = requirements .windows diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index bf3afd7bb9..77fc4c43fe 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1,4 +1,5 @@ use super::apps_processor::APP_READ_MAX_IDS; +use super::config_processor::reload_user_config; use super::*; use crate::error_code::internal_error; use crate::error_code::invalid_request; @@ -1514,7 +1515,10 @@ impl PluginRequestProcessor { } }; - self.on_effective_plugins_changed().await; + self.clear_plugin_related_caches(); + reload_user_config(&self.config_manager, &self.thread_manager).await; + self.thread_manager.invalidate_mcp_runtimes().await; + self.thread_manager.refresh_hook_runtimes().await; let plugin_mcp_servers = load_configured_plugin_mcp_servers( result.installed_path.as_path(), diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 6843820f6c..9807718c3c 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -494,7 +494,8 @@ async fn plugin_upgrade_refreshes_hook_runtime_for_loaded_session() -> Result<() mcp.read_stream_until_notification_message("turn/completed"), ) .await??; - assert_eq!(std::fs::read_to_string(&hook_log_path)?, "1\n2\n"); + // Installation also applies the staged user-config change to this session. + assert_eq!(std::fs::read_to_string(&hook_log_path)?, "1\n"); let hooks_list_id = mcp .send_hooks_list_request(HooksListParams { @@ -544,7 +545,7 @@ async fn plugin_upgrade_refreshes_hook_runtime_for_loaded_session() -> Result<() .await?; let _: ThreadArchiveResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(archive_id)).await??; - assert_eq!(std::fs::read_to_string(&hook_log_path)?, "1\n2\n3\n"); + assert_eq!(std::fs::read_to_string(&hook_log_path)?, "1\n3\n"); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 9509fff345..1e4adf9034 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -30,11 +30,15 @@ use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginResponse; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::McpServerToolCallResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; use codex_config::types::AuthCredentialsStoreMode; use codex_http_client::HttpClientBuilder; use codex_rmcp_client::McpOAuthCallbackMode; @@ -2411,6 +2415,12 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; + let request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let ThreadStartResponse { thread, .. } = + timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; + let request_id = mcp .send_plugin_install_request(PluginInstallParams { marketplace_path: Some(marketplace_path), @@ -2425,6 +2435,22 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains("[mcp_servers.sample-mcp]")); + let request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id, + server: "sample-mcp".to_string(), + tool: "echo".to_string(), + arguments: Some(json!({ "message": "installed in the same thread" })), + meta: None, + }) + .await?; + let response: McpServerToolCallResponse = + timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; + assert_eq!( + response.structured_content, + Some(json!({ "echo": "ECHOING: installed in the same thread", "env": null })), + ); + let request_id = mcp .send_list_mcp_server_status_request(ListMcpServerStatusParams { cursor: None,