diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6ccec6fe8..6138c86d2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1280,6 +1280,10 @@ }, "PluginInstallParams": { "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local install flow.", + "type": "boolean" + }, "marketplacePath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -1329,6 +1333,10 @@ }, "PluginUninstallParams": { "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local uninstall flow.", + "type": "boolean" + }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 097b204f4..11bdd8938 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9180,6 +9180,10 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local install flow.", + "type": "boolean" + }, "marketplacePath": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -9500,6 +9504,10 @@ "PluginUninstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local uninstall flow.", + "type": "boolean" + }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b298a4980..3a1af8abc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5968,6 +5968,10 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local install flow.", + "type": "boolean" + }, "marketplacePath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -6288,6 +6292,10 @@ "PluginUninstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local uninstall flow.", + "type": "boolean" + }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json index 9e9bf6de8..689070531 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json @@ -7,6 +7,10 @@ } }, "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local install flow.", + "type": "boolean" + }, "marketplacePath": { "$ref": "#/definitions/AbsolutePathBuf" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json index 5b7e0a592..a6d7ec78b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "forceRemoteSync": { + "description": "When true, apply the remote plugin change before the local uninstall flow.", + "type": "boolean" + }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts index 86326b130..190dee04c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, }; +export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, +/** + * When true, apply the remote plugin change before the local install flow. + */ +forceRemoteSync?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts index e7f52c0eb..b92a21c9b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts @@ -2,4 +2,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginUninstallParams = { pluginId: string, }; +export type PluginUninstallParams = { pluginId: string, +/** + * When true, apply the remote plugin change before the local uninstall flow. + */ +forceRemoteSync?: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2250fd73e..0f17889a4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3396,6 +3396,9 @@ pub struct SkillsConfigWriteResponse { pub struct PluginInstallParams { pub marketplace_path: AbsolutePathBuf, pub plugin_name: String, + /// When true, apply the remote plugin change before the local install flow. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3411,6 +3414,9 @@ pub struct PluginInstallResponse { #[ts(export_to = "v2/")] pub struct PluginUninstallParams { pub plugin_id: String, + /// When true, apply the remote plugin change before the local uninstall flow. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -7571,6 +7577,69 @@ mod tests { ); } + #[test] + fn plugin_install_params_serialization_uses_force_remote_sync() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginInstallParams { + marketplace_path: marketplace_path.clone(), + plugin_name: "gmail".to_string(), + force_remote_sync: false, + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::to_value(PluginInstallParams { + marketplace_path, + plugin_name: "gmail".to_string(), + force_remote_sync: true, + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + }), + ); + } + + #[test] + fn plugin_uninstall_params_serialization_uses_force_remote_sync() { + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + force_remote_sync: false, + }) + .unwrap(), + json!({ + "pluginId": "gmail@openai-curated", + }), + ); + + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + force_remote_sync: true, + }) + .unwrap(), + json!({ + "pluginId": "gmail@openai-curated", + "forceRemoteSync": true, + }), + ); + } + #[test] fn codex_error_info_serializes_http_status_code_in_camel_case() { let value = CodexErrorInfo::ResponseTooManyFailedAttempts { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6659389a4..6472b78bf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5679,6 +5679,7 @@ impl CodexMessageProcessor { let PluginInstallParams { marketplace_path, plugin_name, + force_remote_sync, } = params; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); @@ -5688,7 +5689,23 @@ impl CodexMessageProcessor { marketplace_path, }; - match plugins_manager.install_plugin(request).await { + let install_result = if force_remote_sync { + let config = match self.load_latest_config(config_cwd.clone()).await { + Ok(config) => config, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; + let auth = self.auth_manager.auth().await; + plugins_manager + .install_plugin_with_remote_sync(&config, auth.as_ref(), request) + .await + } else { + plugins_manager.install_plugin(request).await + }; + + match install_result { Ok(result) => { let config = match self.load_latest_config(config_cwd).await { Ok(config) => config, @@ -5789,6 +5806,13 @@ impl CodexMessageProcessor { ) .await; } + CorePluginInstallError::Remote(err) => { + self.send_internal_error( + request_id, + format!("failed to enable remote plugin: {err}"), + ) + .await; + } CorePluginInstallError::Join(err) => { self.send_internal_error( request_id, @@ -5813,9 +5837,29 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: PluginUninstallParams, ) { + let PluginUninstallParams { + plugin_id, + force_remote_sync, + } = params; let plugins_manager = self.thread_manager.plugins_manager(); - match plugins_manager.uninstall_plugin(params.plugin_id).await { + let uninstall_result = if force_remote_sync { + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; + let auth = self.auth_manager.auth().await; + plugins_manager + .uninstall_plugin_with_remote_sync(&config, auth.as_ref(), plugin_id) + .await + } else { + plugins_manager.uninstall_plugin(plugin_id).await + }; + + match uninstall_result { Ok(()) => { self.clear_plugin_related_caches(); self.outgoing @@ -5837,6 +5881,13 @@ impl CodexMessageProcessor { ) .await; } + CorePluginUninstallError::Remote(err) => { + self.send_internal_error( + request_id, + format!("failed to uninstall remote plugin: {err}"), + ) + .await; + } CorePluginUninstallError::Join(err) => { self.send_internal_error( request_id, 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 54afd89f3..06b0fcb55 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -44,6 +44,12 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -86,6 +92,7 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() - codex_home.path().join("missing-marketplace.json"), )?, plugin_name: "missing-plugin".to_string(), + force_remote_sync: false, }) .await?; @@ -124,6 +131,7 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re .send_plugin_install_request(PluginInstallParams { marketplace_path, plugin_name: "sample-plugin".to_string(), + force_remote_sync: false, }) .await?; @@ -138,6 +146,76 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re Ok(()) } +#[tokio::test] +async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()> +{ + let server = MockServer::start().await; + let codex_home = TempDir::new()?; + write_plugin_remote_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + None, + None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + Mock::given(method("POST")) + .and(path("/backend-api/plugins/sample-plugin@debug/enable")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"id":"sample-plugin@debug","enabled":true}"#), + ) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path, + plugin_name: "sample-plugin".to_string(), + force_remote_sync: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + assert_eq!(response.apps_needing_auth, Vec::::new()); + + assert!( + codex_home + .path() + .join("plugins/cache/debug/sample-plugin/local/.codex-plugin/plugin.json") + .is_file() + ); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); + assert!(config.contains("enabled = true")); + Ok(()) +} + #[tokio::test] async fn plugin_install_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; @@ -172,6 +250,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { .send_plugin_install_request(PluginInstallParams { marketplace_path, plugin_name: "sample-plugin".to_string(), + force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( @@ -285,6 +364,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { .send_plugin_install_request(PluginInstallParams { marketplace_path, plugin_name: "sample-plugin".to_string(), + force_remote_sync: false, }) .await?; @@ -367,6 +447,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { .send_plugin_install_request(PluginInstallParams { marketplace_path, plugin_name: "sample-plugin".to_string(), + force_remote_sync: false, }) .await?; @@ -549,6 +630,23 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std:: ) } +fn write_plugin_remote_sync_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +"# + ), + ) +} + fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 615a164dc..42a62aeff 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -630,6 +630,7 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu )?; write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail", "calendar"])?; write_installed_plugin(&codex_home, "openai-curated", "linear")?; + write_installed_plugin(&codex_home, "openai-curated", "gmail")?; write_installed_plugin(&codex_home, "openai-curated", "calendar")?; Mock::given(method("GET")) @@ -676,14 +677,14 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu .collect::>(), vec![ ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), true, false), + ("gmail@openai-curated".to_string(), false, false), ("calendar@openai-curated".to_string(), false, false), ] ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); + assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); assert!( @@ -693,12 +694,10 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu .is_dir() ); assert!( - codex_home + !codex_home .path() - .join(format!( - "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}" - )) - .is_dir() + .join("plugins/cache/openai-curated/gmail") + .exists() ); assert!( !codex_home @@ -741,6 +740,9 @@ plugins = true [plugins."linear@openai-curated"] enabled = false +[plugins."gmail@openai-curated"] +enabled = false + [plugins."calendar@openai-curated"] enabled = true "# diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index b068552a8..5e2f661b5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -16,6 +16,12 @@ use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -38,6 +44,7 @@ enabled = true let params = PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), + force_remote_sync: false, }; let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; @@ -70,6 +77,74 @@ enabled = true Ok(()) } +#[tokio::test] +async fn plugin_uninstall_force_remote_sync_calls_remote_uninstall_first() -> Result<()> { + let server = MockServer::start().await; + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "debug", "sample-plugin")?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("POST")) + .and(path("/backend-api/plugins/sample-plugin@debug/uninstall")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"id":"sample-plugin@debug","enabled":false}"#), + ) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "sample-plugin@debug".to_string(), + force_remote_sync: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + assert!( + !codex_home + .path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); + Ok(()) +} + #[tokio::test] async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; @@ -97,6 +172,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), + force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 11ce8c161..4b2496824 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -6,12 +6,18 @@ use super::marketplace::MarketplaceError; use super::marketplace::MarketplacePluginAuthPolicy; use super::marketplace::MarketplacePluginInstallPolicy; use super::marketplace::MarketplacePluginSourceSummary; +use super::marketplace::ResolvedMarketplacePlugin; use super::marketplace::list_marketplaces; use super::marketplace::load_marketplace_summary; use super::marketplace::resolve_marketplace_plugin; use super::plugin_manifest_name; use super::plugin_manifest_paths; use super::read_curated_plugins_sha; +use super::remote::RemotePluginFetchError; +use super::remote::RemotePluginMutationError; +use super::remote::enable_remote_plugin; +use super::remote::fetch_remote_plugin_status; +use super::remote::uninstall_remote_plugin; use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; use super::store::PluginIdError; @@ -31,7 +37,6 @@ use crate::config::profile::ConfigProfile; use crate::config::types::McpServerConfig; use crate::config::types::PluginConfig; use crate::config_loader::ConfigLayerStack; -use crate::default_client::build_reqwest_client; use crate::features::Feature; use crate::features::FeatureOverrides; use crate::features::Features; @@ -55,7 +60,6 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::time::Duration; use toml_edit::value; use tracing::info; use tracing::warn; @@ -64,7 +68,6 @@ const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; -const REMOTE_PLUGIN_SYNC_TIMEOUT: Duration = Duration::from_secs(30); static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; @@ -311,6 +314,7 @@ pub struct RemotePluginSyncResult { /// Plugin ids whose local config was changed to enabled. pub enabled_plugin_ids: Vec, /// Plugin ids whose local config was changed to disabled. + /// This is not populated by `sync_plugins_from_remote`. pub disabled_plugin_ids: Vec, /// Plugin ids removed from local cache or plugin config. pub uninstalled_plugin_ids: Vec, @@ -384,29 +388,24 @@ pub enum PluginRemoteSyncError { } impl PluginRemoteSyncError { - fn auth_token(source: std::io::Error) -> Self { - Self::AuthToken(source) - } - - fn request(url: String, source: reqwest::Error) -> Self { - Self::Request { url, source } - } - fn join(source: tokio::task::JoinError) -> Self { Self::Join(source) } } -#[derive(Debug, Deserialize)] -struct RemotePluginStatusSummary { - name: String, - #[serde(default = "default_remote_marketplace_name")] - marketplace_name: String, - enabled: bool, -} - -fn default_remote_marketplace_name() -> String { - OPENAI_CURATED_MARKETPLACE_NAME.to_string() +impl From for PluginRemoteSyncError { + fn from(value: RemotePluginFetchError) -> Self { + match value { + RemotePluginFetchError::AuthRequired => Self::AuthRequired, + RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode, + RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source), + RemotePluginFetchError::Request { url, source } => Self::Request { url, source }, + RemotePluginFetchError::UnexpectedStatus { url, status, body } => { + Self::UnexpectedStatus { url, status, body } + } + RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source }, + } + } } pub struct PluginsManager { @@ -486,6 +485,30 @@ impl PluginsManager { request: PluginInstallRequest, ) -> Result { let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; + self.install_resolved_plugin(resolved).await + } + + pub async fn install_plugin_with_remote_sync( + &self, + config: &Config, + auth: Option<&CodexAuth>, + request: PluginInstallRequest, + ) -> Result { + let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; + let plugin_id = resolved.plugin_id.as_key(); + // This only forwards the backend mutation before the local install flow. We rely on + // `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra + // reconcile pass here. + enable_remote_plugin(config, auth, &plugin_id) + .await + .map_err(PluginInstallError::from)?; + self.install_resolved_plugin(resolved).await + } + + async fn install_resolved_plugin( + &self, + resolved: ResolvedMarketplacePlugin, + ) -> Result { let auth_policy = resolved.auth_policy; let plugin_version = if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { @@ -545,6 +568,27 @@ impl PluginsManager { pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> { let plugin_id = PluginId::parse(&plugin_id)?; + self.uninstall_plugin_id(plugin_id).await + } + + pub async fn uninstall_plugin_with_remote_sync( + &self, + config: &Config, + auth: Option<&CodexAuth>, + plugin_id: String, + ) -> Result<(), PluginUninstallError> { + let plugin_id = PluginId::parse(&plugin_id)?; + let plugin_key = plugin_id.as_key(); + // This only forwards the backend mutation before the local uninstall flow. We rely on + // `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra + // reconcile pass here. + uninstall_remote_plugin(config, auth, &plugin_key) + .await + .map_err(PluginUninstallError::from)?; + self.uninstall_plugin_id(plugin_id).await + } + + async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { let plugin_telemetry = self .store .active_plugin_root(&plugin_id) @@ -581,7 +625,9 @@ impl PluginsManager { auth: Option<&CodexAuth>, ) -> Result { info!("starting remote plugin sync"); - let remote_plugins = fetch_remote_plugin_status(config, auth).await?; + let remote_plugins = fetch_remote_plugin_status(config, auth) + .await + .map_err(PluginRemoteSyncError::from)?; let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path()); let curated_marketplace_path = AbsolutePathBuf::try_from( @@ -640,7 +686,7 @@ impl PluginsManager { )); } - let mut remote_enabled_by_name = HashMap::::new(); + let mut remote_installed_plugin_names = HashSet::::new(); for plugin in remote_plugins { if plugin.marketplace_name != marketplace_name { return Err(PluginRemoteSyncError::UnknownRemoteMarketplace { @@ -655,10 +701,13 @@ impl PluginsManager { ); continue; } - if remote_enabled_by_name - .insert(plugin.name.clone(), plugin.enabled) - .is_some() - { + // For now, sync treats remote `enabled = false` as uninstall rather than a distinct + // disabled state. + // TODO: Switch sync to `plugins/installed` so install and enable states stay distinct. + if !plugin.enabled { + continue; + } + if !remote_installed_plugin_names.insert(plugin.name.clone()) { return Err(PluginRemoteSyncError::DuplicateRemotePlugin { plugin_name: plugin.name, }); @@ -669,7 +718,7 @@ impl PluginsManager { let mut installs = Vec::new(); let mut uninstalls = Vec::new(); let mut result = RemotePluginSyncResult::default(); - let remote_plugin_count = remote_enabled_by_name.len(); + let remote_plugin_count = remote_installed_plugin_names.len(); let local_plugin_count = local_plugins.len(); for (plugin_name, plugin_id, source_path, current_enabled, installed_version) in @@ -677,7 +726,7 @@ impl PluginsManager { { let plugin_key = plugin_id.as_key(); let is_installed = installed_version.is_some(); - if let Some(enabled) = remote_enabled_by_name.get(&plugin_name).copied() { + if remote_installed_plugin_names.contains(&plugin_name) { if !is_installed { installs.push(( source_path, @@ -689,16 +738,11 @@ impl PluginsManager { result.installed_plugin_ids.push(plugin_key.clone()); } - if current_enabled != Some(enabled) { - if enabled { - result.enabled_plugin_ids.push(plugin_key.clone()); - } else { - result.disabled_plugin_ids.push(plugin_key.clone()); - } - + if current_enabled != Some(true) { + result.enabled_plugin_ids.push(plugin_key.clone()); config_edits.push(ConfigEdit::SetPath { segments: vec!["plugins".to_string(), plugin_key, "enabled".to_string()], - value: value(enabled), + value: value(true), }); } } else { @@ -990,52 +1034,14 @@ impl PluginsManager { } } -async fn fetch_remote_plugin_status( - config: &Config, - auth: Option<&CodexAuth>, -) -> Result, PluginRemoteSyncError> { - let Some(auth) = auth else { - return Err(PluginRemoteSyncError::AuthRequired); - }; - if !auth.is_chatgpt_auth() { - return Err(PluginRemoteSyncError::UnsupportedAuthMode); - } - - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/list"); - let client = build_reqwest_client(); - let token = auth - .get_token() - .map_err(PluginRemoteSyncError::auth_token)?; - let mut request = client - .get(&url) - .timeout(REMOTE_PLUGIN_SYNC_TIMEOUT) - .bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); - } - - let response = request - .send() - .await - .map_err(|source| PluginRemoteSyncError::request(url.clone(), source))?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(PluginRemoteSyncError::UnexpectedStatus { url, status, body }); - } - - serde_json::from_str(&body).map_err(|source| PluginRemoteSyncError::Decode { - url: url.clone(), - source, - }) -} - #[derive(Debug, thiserror::Error)] pub enum PluginInstallError { #[error("{0}")] Marketplace(#[from] MarketplaceError), + #[error("{0}")] + Remote(#[from] RemotePluginMutationError), + #[error("{0}")] Store(#[from] PluginStoreError), @@ -1070,6 +1076,9 @@ pub enum PluginUninstallError { #[error("{0}")] InvalidPluginId(#[from] PluginIdError), + #[error("{0}")] + Remote(#[from] RemotePluginMutationError), + #[error("{0}")] Store(#[from] PluginStoreError), diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 18a293eb7..70b437016 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1312,6 +1312,11 @@ async fn sync_plugins_from_remote_reconciles_cache_and_config() { "linear/local", "linear", ); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "gmail/local", + "gmail", + ); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "calendar/local", @@ -1325,6 +1330,9 @@ plugins = true [plugins."linear@openai-curated"] enabled = false +[plugins."gmail@openai-curated"] +enabled = false + [plugins."calendar@openai-curated"] enabled = true "#, @@ -1358,10 +1366,13 @@ enabled = true assert_eq!( result, RemotePluginSyncResult { - installed_plugin_ids: vec!["gmail@openai-curated".to_string()], + installed_plugin_ids: Vec::new(), enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: vec!["gmail@openai-curated".to_string()], - uninstalled_plugin_ids: vec!["calendar@openai-curated".to_string()], + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: vec![ + "gmail@openai-curated".to_string(), + "calendar@openai-curated".to_string(), + ], } ); @@ -1371,11 +1382,9 @@ enabled = true .is_dir() ); assert!( - tmp.path() - .join(format!( - "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}" - )) - .is_dir() + !tmp.path() + .join("plugins/cache/openai-curated/gmail") + .exists() ); assert!( !tmp.path() @@ -1385,9 +1394,8 @@ enabled = true let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); assert!(config.contains("enabled = true")); - assert!(config.contains("enabled = false")); + assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); let synced_config = load_config(tmp.path(), tmp.path()).await; @@ -1405,7 +1413,7 @@ enabled = true .collect::>(), vec![ ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), true, false), + ("gmail@openai-curated".to_string(), false, false), ("calendar@openai-curated".to_string(), false, false), ] ); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 7eb76798a..9f540d7cd 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -3,6 +3,7 @@ mod injection; mod manager; mod manifest; mod marketplace; +mod remote; mod render; mod store; mod toggles; diff --git a/codex-rs/core/src/plugins/remote.rs b/codex-rs/core/src/plugins/remote.rs new file mode 100644 index 000000000..242b6d3ca --- /dev/null +++ b/codex-rs/core/src/plugins/remote.rs @@ -0,0 +1,266 @@ +use crate::auth::CodexAuth; +use crate::config::Config; +use crate::default_client::build_reqwest_client; +use serde::Deserialize; +use std::time::Duration; +use url::Url; + +const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; +const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct RemotePluginStatusSummary { + pub(crate) name: String, + #[serde(default = "default_remote_marketplace_name")] + pub(crate) marketplace_name: String, + pub(crate) enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemotePluginMutationResponse { + pub id: String, + pub enabled: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginMutationError { + #[error("chatgpt authentication required for remote plugin mutation")] + AuthRequired, + + #[error( + "chatgpt authentication required for remote plugin mutation; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin mutation: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("invalid chatgpt base url for remote plugin mutation: {0}")] + InvalidBaseUrl(#[source] url::ParseError), + + #[error("chatgpt base url cannot be used for plugin mutation")] + InvalidBaseUrlPath, + + #[error("failed to send remote plugin mutation request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin mutation failed with status {status} from {url}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin mutation response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + + #[error( + "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" + )] + UnexpectedPluginId { expected: String, actual: String }, + + #[error( + "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + )] + UnexpectedEnabledState { + plugin_id: String, + expected_enabled: bool, + actual_enabled: bool, + }, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum RemotePluginFetchError { + #[error("chatgpt authentication required to sync remote plugins")] + AuthRequired, + + #[error( + "chatgpt authentication required to sync remote plugins; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin sync: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("failed to send remote plugin sync request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin sync response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, +} + +pub(crate) async fn fetch_remote_plugin_status( + config: &Config, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginFetchError> { + let Some(auth) = auth else { + return Err(RemotePluginFetchError::AuthRequired); + }; + if !auth.is_chatgpt_auth() { + return Err(RemotePluginFetchError::UnsupportedAuthMode); + } + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/list"); + let client = build_reqwest_client(); + let token = auth + .get_token() + .map_err(RemotePluginFetchError::AuthToken)?; + let mut request = client + .get(&url) + .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) + .bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + + let response = request + .send() + .await + .map_err(|source| RemotePluginFetchError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { + url: url.clone(), + source, + }) +} + +pub(crate) async fn enable_remote_plugin( + config: &Config, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?; + Ok(()) +} + +pub(crate) async fn uninstall_remote_plugin( + config: &Config, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?; + Ok(()) +} + +fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> { + let Some(auth) = auth else { + return Err(RemotePluginMutationError::AuthRequired); + }; + if !auth.is_chatgpt_auth() { + return Err(RemotePluginMutationError::UnsupportedAuthMode); + } + Ok(auth) +} + +fn default_remote_marketplace_name() -> String { + DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() +} + +async fn post_remote_plugin_mutation( + config: &Config, + auth: Option<&CodexAuth>, + plugin_id: &str, + action: &str, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let url = remote_plugin_mutation_url(config, plugin_id, action)?; + let client = build_reqwest_client(); + let token = auth + .get_token() + .map_err(RemotePluginMutationError::AuthToken)?; + let mut request = client + .post(url.clone()) + .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) + .bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + + let response = request + .send() + .await + .map_err(|source| RemotePluginMutationError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body }); + } + + let parsed: RemotePluginMutationResponse = + serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode { + url: url.clone(), + source, + })?; + let expected_enabled = action == "enable"; + if parsed.id != plugin_id { + return Err(RemotePluginMutationError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: parsed.id, + }); + } + if parsed.enabled != expected_enabled { + return Err(RemotePluginMutationError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled, + actual_enabled: parsed.enabled, + }); + } + + Ok(parsed) +} + +fn remote_plugin_mutation_url( + config: &Config, + plugin_id: &str, + action: &str, +) -> Result { + let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) + .map_err(RemotePluginMutationError::InvalidBaseUrl)?; + { + let mut segments = url + .path_segments_mut() + .map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?; + segments.pop_if_empty(); + segments.push("plugins"); + segments.push(plugin_id); + segments.push(action); + } + Ok(url.to_string()) +} diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py index e953baaac..2c000cc22 100644 --- a/sdk/python/src/codex_app_server/generated/v2_all.py +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -339,6 +339,7 @@ class CodexErrorInfo( class CollabAgentStatus(Enum): pending_init = "pendingInit" running = "running" + interrupted = "interrupted" completed = "completed" errored = "errored" shutdown = "shutdown" @@ -746,6 +747,7 @@ class DynamicToolSpec(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + defer_loading: Annotated[bool | None, Field(alias="deferLoading")] = None description: str input_schema: Annotated[Any, Field(alias="inputSchema")] name: str @@ -1631,6 +1633,13 @@ class PluginInstallParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + force_remote_sync: Annotated[ + bool | None, + Field( + alias="forceRemoteSync", + description="When true, apply the remote plugin change before the local install flow.", + ), + ] = None marketplace_path: Annotated[AbsolutePathBuf, Field(alias="marketplacePath")] plugin_name: Annotated[str, Field(alias="pluginName")] @@ -1657,7 +1666,13 @@ class PluginInterface(BaseModel): capabilities: list[str] category: str | None = None composer_icon: Annotated[AbsolutePathBuf | None, Field(alias="composerIcon")] = None - default_prompt: Annotated[str | None, Field(alias="defaultPrompt")] = None + default_prompt: Annotated[ + list[str] | None, + Field( + alias="defaultPrompt", + description="Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + ), + ] = None developer_name: Annotated[str | None, Field(alias="developerName")] = None display_name: Annotated[str | None, Field(alias="displayName")] = None logo: AbsolutePathBuf | None = None @@ -1729,6 +1744,13 @@ class PluginUninstallParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + force_remote_sync: Annotated[ + bool | None, + Field( + alias="forceRemoteSync", + description="When true, apply the remote plugin change before the local uninstall flow.", + ), + ] = None plugin_id: Annotated[str, Field(alias="pluginId")]