diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 93199094c..048a1818f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1131,6 +1131,10 @@ "array", "null" ] + }, + "forceRemoteSync": { + "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", + "type": "boolean" } }, "type": "object" 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 228a49d35..bc6f0c748 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 @@ -12820,6 +12820,10 @@ "array", "null" ] + }, + "forceRemoteSync": { + "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", + "type": "boolean" } }, "title": "PluginListParams", @@ -12833,6 +12837,12 @@ "$ref": "#/definitions/v2/PluginMarketplaceEntry" }, "type": "array" + }, + "remoteSyncError": { + "type": [ + "string", + "null" + ] } }, "required": [ 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 b5fdebe7d..b67bb447a 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 @@ -9207,6 +9207,10 @@ "array", "null" ] + }, + "forceRemoteSync": { + "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", + "type": "boolean" } }, "title": "PluginListParams", @@ -9220,6 +9224,12 @@ "$ref": "#/definitions/PluginMarketplaceEntry" }, "type": "array" + }, + "remoteSyncError": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 27ea8c4df..669ff92b9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -16,6 +16,10 @@ "array", "null" ] + }, + "forceRemoteSync": { + "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", + "type": "boolean" } }, "title": "PluginListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 88ccb5103..e6d638c3c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -196,6 +196,12 @@ "$ref": "#/definitions/PluginMarketplaceEntry" }, "type": "array" + }, + "remoteSyncError": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts index 078feca20..07ecee5e5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -8,4 +8,9 @@ export type PluginListParams = { * Optional working directories used to discover repo marketplaces. When omitted, * only home-scoped marketplaces and the official curated marketplace are considered. */ -cwds?: Array | null, }; +cwds?: Array | null, +/** + * When true, reconcile the official curated marketplace against the remote plugin state + * before listing marketplaces. + */ +forceRemoteSync?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts index 7c3cc692c..c6de9e7e8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; -export type PluginListResponse = { marketplaces: Array, }; +export type PluginListResponse = { marketplaces: Array, remoteSyncError: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e557ef586..035ec5499 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2820,6 +2820,10 @@ pub struct PluginListParams { /// only home-scoped marketplaces and the official curated marketplace are considered. #[ts(optional = nullable)] pub cwds: Option>, + /// When true, reconcile the official curated marketplace against the remote plugin state + /// before listing marketplaces. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -2827,6 +2831,7 @@ pub struct PluginListParams { #[ts(export_to = "v2/")] pub struct PluginListResponse { pub marketplaces: Vec, + pub remote_sync_error: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -6511,6 +6516,32 @@ mod tests { ); } + #[test] + fn plugin_list_params_serialization_uses_force_remote_sync() { + assert_eq!( + serde_json::to_value(PluginListParams { + cwds: None, + force_remote_sync: false, + }) + .unwrap(), + json!({ + "cwds": null, + }), + ); + + assert_eq!( + serde_json::to_value(PluginListParams { + cwds: None, + force_remote_sync: true, + }) + .unwrap(), + json!({ + "cwds": null, + "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/README.md b/codex-rs/app-server/README.md index 64de7c3f5..d7ee6a8d1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -157,7 +157,7 @@ Example with notification opt-out: - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `plugin/list` — list discovered plugin marketplaces, including plugin id, installed/enabled state, and optional interface metadata (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces and plugin state. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**). - `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1ef7f6557..2955b0da6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5303,15 +5303,53 @@ impl CodexMessageProcessor { async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { let plugins_manager = self.thread_manager.plugins_manager(); - let roots = params.cwds.unwrap_or_default(); + let PluginListParams { + cwds, + force_remote_sync, + } = params; + let roots = cwds.unwrap_or_default(); - let config = match self.load_latest_config(None).await { + let mut config = match self.load_latest_config(None).await { Ok(config) => config, Err(err) => { self.outgoing.send_error(request_id, err).await; return; } }; + let mut remote_sync_error = None; + + if force_remote_sync { + let auth = self.auth_manager.auth().await; + match plugins_manager + .sync_plugins_from_remote(&config, auth.as_ref()) + .await + { + Ok(sync_result) => { + info!( + installed_plugin_ids = ?sync_result.installed_plugin_ids, + enabled_plugin_ids = ?sync_result.enabled_plugin_ids, + disabled_plugin_ids = ?sync_result.disabled_plugin_ids, + uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids, + "completed plugin/list remote sync" + ); + } + Err(err) => { + warn!( + error = %err, + "plugin/list remote sync failed; returning local marketplace state" + ); + remote_sync_error = Some(err.to_string()); + } + } + + config = match self.load_latest_config(None).await { + Ok(config) => config, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; + } let data = match tokio::task::spawn_blocking(move || { let marketplaces = plugins_manager.list_marketplaces_for_config(&config, &roots)?; @@ -5375,7 +5413,13 @@ impl CodexMessageProcessor { }; self.outgoing - .send_response(request_id, PluginListResponse { marketplaces: data }) + .send_response( + request_id, + PluginListResponse { + marketplaces: data, + remote_sync_error, + }, + ) .await; } 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 a202dcde9..53b258d19 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1,18 +1,27 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; 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); @@ -41,6 +50,7 @@ async fn plugin_list_returns_invalid_request_for_invalid_marketplace_file() -> R let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + force_remote_sync: false, }) .await?; @@ -112,7 +122,10 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + force_remote_sync: false, + }) .await?; let response: JSONRPCResponse = timeout( @@ -180,6 +193,7 @@ enabled = false let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + force_remote_sync: false, }) .await?; @@ -303,6 +317,7 @@ enabled = false AbsolutePathBuf::try_from(workspace_enabled.path())?, AbsolutePathBuf::try_from(workspace_default.path())?, ]), + force_remote_sync: false, }) .await?; @@ -377,6 +392,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + force_remote_sync: false, }) .await?; @@ -439,6 +455,144 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res Ok(()) } +#[tokio::test] +async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> { + let codex_home = TempDir::new()?; + write_plugin_sync_config(codex_home.path(), "https://chatgpt.com/backend-api/")?; + write_openai_curated_marketplace(codex_home.path(), &["linear"])?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + force_remote_sync: true, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .remote_sync_error + .as_deref() + .is_some_and(|message| message.contains("chatgpt authentication required")) + ); + let curated_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("expected openai-curated marketplace entry"); + assert_eq!( + curated_marketplace + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) + .collect::>(), + vec![("linear@openai-curated".to_string(), true, false)] + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugin_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, + )?; + 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", "calendar")?; + + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, + {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} +]"#, + )) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + force_remote_sync: true, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + assert_eq!(response.remote_sync_error, None); + + let curated_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("expected openai-curated marketplace entry"); + assert_eq!( + curated_marketplace + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ("linear@openai-curated".to_string(), true, true), + ("gmail@openai-curated".to_string(), true, 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."calendar@openai-curated"]"#)); + + assert!( + codex_home + .path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + codex_home + .path() + .join("plugins/cache/openai-curated/gmail/local") + .is_dir() + ); + assert!( + !codex_home + .path() + .join("plugins/cache/openai-curated/calendar") + .exists() + ); + Ok(()) +} + fn write_installed_plugin( codex_home: &TempDir, marketplace_name: &str, @@ -457,3 +611,68 @@ fn write_installed_plugin( )?; Ok(()) } + +fn write_plugin_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 + +[plugins."linear@openai-curated"] +enabled = false + +[plugins."calendar@openai-curated"] +enabled = true +"# + ), + ) +} + +fn write_openai_curated_marketplace( + codex_home: &std::path::Path, + plugin_names: &[&str], +) -> std::io::Result<()> { + let curated_root = codex_home.join(".tmp/plugins"); + std::fs::create_dir_all(curated_root.join(".git"))?; + std::fs::create_dir_all(curated_root.join(".agents/plugins"))?; + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + std::fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "openai-curated", + "plugins": [ +{plugins} + ] +}}"# + ), + )?; + + for plugin_name in plugin_names { + let plugin_root = curated_root.join(format!("plugins/{plugin_name}/.codex-plugin")); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + } + Ok(()) +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 153e4ee4d..cd2b82c3d 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -5,6 +5,7 @@ use super::manifest::PluginManifestInterfaceSummary; use super::marketplace::MarketplaceError; use super::marketplace::MarketplacePluginSourceSummary; 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; @@ -15,6 +16,7 @@ use super::store::PluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; use super::sync_openai_plugins_repo; +use crate::auth::CodexAuth; use crate::config::Config; use crate::config::ConfigService; use crate::config::ConfigServiceError; @@ -25,6 +27,7 @@ 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; @@ -43,12 +46,17 @@ use std::path::PathBuf; 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; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; const DISABLE_CURATED_PLUGIN_SYNC_ENV_VAR: &str = "CODEX_DISABLE_CURATED_PLUGIN_SYNC"; +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); #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -206,6 +214,111 @@ impl PluginLoadOutcome { } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RemotePluginSyncResult { + /// Plugin ids newly installed into the local plugin cache. + pub installed_plugin_ids: Vec, + /// Plugin ids whose local config was changed to enabled. + pub enabled_plugin_ids: Vec, + /// Plugin ids whose local config was changed to disabled. + pub disabled_plugin_ids: Vec, + /// Plugin ids removed from local cache or plugin config. + pub uninstalled_plugin_ids: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PluginRemoteSyncError { + #[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, + }, + + #[error("local curated marketplace is not available")] + LocalMarketplaceNotFound, + + #[error("remote marketplace `{marketplace_name}` is not available locally")] + UnknownRemoteMarketplace { marketplace_name: String }, + + #[error("duplicate remote plugin `{plugin_name}` in sync response")] + DuplicateRemotePlugin { plugin_name: String }, + + #[error( + "remote plugin `{plugin_name}` was not found in local marketplace `{marketplace_name}`" + )] + UnknownRemotePlugin { + plugin_name: String, + marketplace_name: String, + }, + + #[error("{0}")] + InvalidPluginId(#[from] PluginIdError), + + #[error("{0}")] + Marketplace(#[from] MarketplaceError), + + #[error("{0}")] + Store(#[from] PluginStoreError), + + #[error("{0}")] + Config(#[from] anyhow::Error), + + #[error("failed to join remote plugin sync task: {0}")] + Join(#[from] tokio::task::JoinError), +} + +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() +} + pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, @@ -311,6 +424,169 @@ impl PluginsManager { Ok(()) } + pub async fn sync_plugins_from_remote( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> Result { + info!("starting remote plugin sync"); + let remote_plugins = fetch_remote_plugin_status(config, auth).await?; + 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( + curated_marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?; + let curated_marketplace = match load_marketplace_summary(&curated_marketplace_path) { + Ok(marketplace) => marketplace, + Err(MarketplaceError::MarketplaceNotFound { .. }) => { + return Err(PluginRemoteSyncError::LocalMarketplaceNotFound); + } + Err(err) => return Err(err.into()), + }; + + let marketplace_name = curated_marketplace.name.clone(); + let mut local_plugins = + Vec::<(String, PluginId, AbsolutePathBuf, Option, bool)>::new(); + let mut local_plugin_names = HashSet::new(); + for plugin in curated_marketplace.plugins { + let plugin_name = plugin.name; + if !local_plugin_names.insert(plugin_name.clone()) { + warn!( + plugin = plugin_name, + marketplace = %marketplace_name, + "ignoring duplicate local plugin entry during remote sync" + ); + continue; + } + + let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?; + let plugin_key = plugin_id.as_key(); + let source_path = match plugin.source { + MarketplacePluginSourceSummary::Local { path } => path, + }; + let current_enabled = configured_plugins + .get(&plugin_key) + .map(|plugin| plugin.enabled); + let is_installed = self.store.is_installed(&plugin_id); + local_plugins.push(( + plugin_name, + plugin_id, + source_path, + current_enabled, + is_installed, + )); + } + + let mut remote_enabled_by_name = HashMap::::new(); + for plugin in remote_plugins { + if plugin.marketplace_name != marketplace_name { + return Err(PluginRemoteSyncError::UnknownRemoteMarketplace { + marketplace_name: plugin.marketplace_name, + }); + } + if !local_plugin_names.contains(&plugin.name) { + warn!( + plugin = plugin.name, + marketplace = %marketplace_name, + "ignoring remote plugin missing from local marketplace during sync" + ); + continue; + } + if remote_enabled_by_name + .insert(plugin.name.clone(), plugin.enabled) + .is_some() + { + return Err(PluginRemoteSyncError::DuplicateRemotePlugin { + plugin_name: plugin.name, + }); + } + } + + let mut config_edits = Vec::new(); + 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 local_plugin_count = local_plugins.len(); + + for (plugin_name, plugin_id, source_path, current_enabled, is_installed) in local_plugins { + let plugin_key = plugin_id.as_key(); + if let Some(enabled) = remote_enabled_by_name.get(&plugin_name).copied() { + if !is_installed { + installs.push((source_path, plugin_id.clone())); + 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()); + } + + config_edits.push(ConfigEdit::SetPath { + segments: vec!["plugins".to_string(), plugin_key, "enabled".to_string()], + value: value(enabled), + }); + } + } else { + if is_installed { + uninstalls.push(plugin_id); + } + if is_installed || current_enabled.is_some() { + result.uninstalled_plugin_ids.push(plugin_key.clone()); + } + if current_enabled.is_some() { + config_edits.push(ConfigEdit::ClearPath { + segments: vec!["plugins".to_string(), plugin_key], + }); + } + } + } + + let store = self.store.clone(); + let store_result = tokio::task::spawn_blocking(move || { + for (source_path, plugin_id) in installs { + store.install(source_path, plugin_id)?; + } + for plugin_id in uninstalls { + store.uninstall(&plugin_id)?; + } + Ok::<(), PluginStoreError>(()) + }) + .await + .map_err(PluginRemoteSyncError::join)?; + if let Err(err) = store_result { + self.clear_cache(); + return Err(err.into()); + } + + let config_result = if config_edits.is_empty() { + Ok(()) + } else { + ConfigEditsBuilder::new(&self.codex_home) + .with_edits(config_edits) + .apply() + .await + }; + self.clear_cache(); + config_result?; + + info!( + marketplace = %marketplace_name, + remote_plugin_count, + local_plugin_count, + installed_plugin_ids = ?result.installed_plugin_ids, + enabled_plugin_ids = ?result.enabled_plugin_ids, + disabled_plugin_ids = ?result.disabled_plugin_ids, + uninstalled_plugin_ids = ?result.uninstalled_plugin_ids, + "completed remote plugin sync" + ); + + Ok(result) + } + pub fn list_marketplaces_for_config( &self, config: &Config, @@ -416,6 +692,47 @@ 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}")] @@ -869,6 +1186,7 @@ struct PluginMcpDiscovery { #[cfg(test)] mod tests { use super::*; + use crate::auth::CodexAuth; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::types::McpServerTransportConfig; @@ -881,6 +1199,12 @@ mod tests { use std::fs; use tempfile::TempDir; use toml::Value; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; fn write_file(path: &Path, contents: &str) { fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); @@ -900,6 +1224,41 @@ mod tests { fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); } + fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + fs::create_dir_all(root.join(".git")).unwrap(); + fs::create_dir_all(root.join(".agents/plugins")).unwrap(); + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + fs::write( + root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ +{plugins} + ] +}}"# + ), + ) + .unwrap(); + for plugin_name in plugin_names { + write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name); + } + } + fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { let mut root = toml::map::Map::new(); @@ -2005,6 +2364,318 @@ enabled = true ); } + #[tokio::test] + async fn sync_plugins_from_remote_reconciles_cache_and_config() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "linear/local", + "linear", + ); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "calendar/local", + "calendar", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false + +[plugins."calendar@openai-curated"] +enabled = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, + {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: vec!["gmail@openai-curated".to_string()], + 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()], + } + ); + + assert!( + tmp.path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + tmp.path() + .join("plugins/cache/openai-curated/gmail/local") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/calendar") + .exists() + ); + + 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."calendar@openai-curated"]"#)); + + let synced_config = load_config(tmp.path(), tmp.path()).await; + let curated_marketplace = manager + .list_marketplaces_for_config(&synced_config, &[]) + .unwrap() + .into_iter() + .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + .unwrap(); + assert_eq!( + curated_marketplace + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ("linear@openai-curated".to_string(), true, true), + ("gmail@openai-curated".to_string(), true, false), + ("calendar@openai-curated".to_string(), false, false), + ] + ); + } + + #[tokio::test] + async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: Vec::new(), + enabled_plugin_ids: Vec::new(), + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], + } + ); + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/linear") + .exists() + ); + } + + #[tokio::test] + async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); + fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "linear/local", + "linear", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let err = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap_err(); + + assert!(matches!( + err, + PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) + if message.contains("plugin source path is not a directory") + )); + assert!( + tmp.path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/gmail") + .exists() + ); + + 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 = false")); + } + + #[tokio::test] + async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + fs::create_dir_all(curated_root.join(".git")).unwrap(); + fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); + fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail-first" + } + }, + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail-second" + } + } + ] +}"#, + ) + .unwrap(); + write_plugin(&curated_root, "plugins/gmail-first", "gmail"); + write_plugin(&curated_root, "plugins/gmail-second", "gmail"); + fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); + fs::write( + curated_root.join("plugins/gmail-second/marker.txt"), + "second", + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: vec!["gmail@openai-curated".to_string()], + enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: Vec::new(), + } + ); + assert_eq!( + fs::read_to_string( + tmp.path() + .join("plugins/cache/openai-curated/gmail/local/marker.txt") + ) + .unwrap(), + "first" + ); + } + #[test] fn load_plugins_ignores_project_config_files() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index f348ce429..e33ef8911 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -106,6 +106,34 @@ pub fn list_marketplaces( list_marketplaces_with_home(additional_roots, home_dir().as_deref()) } +pub(crate) fn load_marketplace_summary( + path: &AbsolutePathBuf, +) -> Result { + let marketplace = load_marketplace(path)?; + let mut plugins = Vec::new(); + + for plugin in marketplace.plugins { + let source_path = resolve_plugin_source_path(path, plugin.source)?; + let source = MarketplacePluginSourceSummary::Local { + path: source_path.clone(), + }; + let interface = load_plugin_manifest(source_path.as_path()) + .and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path())); + + plugins.push(MarketplacePluginSummary { + name: plugin.name, + source, + interface, + }); + } + + Ok(MarketplaceSummary { + name: marketplace.name, + path: path.clone(), + plugins, + }) +} + fn list_marketplaces_with_home( additional_roots: &[AbsolutePathBuf], home_dir: Option<&Path>, @@ -113,29 +141,7 @@ fn list_marketplaces_with_home( let mut marketplaces = Vec::new(); for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { - let marketplace = load_marketplace(&marketplace_path)?; - let mut plugins = Vec::new(); - - for plugin in marketplace.plugins { - let source_path = resolve_plugin_source_path(&marketplace_path, plugin.source)?; - let source = MarketplacePluginSourceSummary::Local { - path: source_path.clone(), - }; - let interface = load_plugin_manifest(source_path.as_path()) - .and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path())); - - plugins.push(MarketplacePluginSummary { - name: plugin.name, - source, - interface, - }); - } - - marketplaces.push(MarketplaceSummary { - name: marketplace.name, - path: marketplace_path, - plugins, - }); + marketplaces.push(load_marketplace_summary(&marketplace_path)?); } Ok(marketplaces) diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 8a34ba9ad..265ef8b75 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -17,8 +17,10 @@ pub use manager::PluginCapabilitySummary; pub use manager::PluginInstallError; pub use manager::PluginInstallRequest; pub use manager::PluginLoadOutcome; +pub use manager::PluginRemoteSyncError; pub use manager::PluginUninstallError; pub use manager::PluginsManager; +pub use manager::RemotePluginSyncResult; pub use manager::load_plugin_apps; pub(crate) use manager::plugin_namespace_for_skill_path; pub use manifest::PluginManifestInterfaceSummary;